mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-11-04 21:31:21 +01:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa469ffdc | ||
|
|
91cf156f3d | ||
|
|
ec72ddf3c2 | ||
|
|
5db97b9cca | ||
|
|
3c916d7505 | ||
|
|
9791c686ee | ||
|
|
ee3c043bdd | ||
|
|
d5b927ae4c | ||
|
|
937d4027bf | ||
|
|
50303e0ba1 | ||
|
|
d05c887163 | ||
|
|
21ab811f91 | ||
|
|
d42015a707 | ||
|
|
93aaa4632c | ||
|
|
f8812e50a5 | ||
|
|
5608dfb71e | ||
|
|
ab350fd5a4 | ||
|
|
e5c961d7fb | ||
|
|
5bd231cbfd | ||
|
|
3eaf06c67c | ||
|
|
6fab323753 | ||
|
|
8d5637b2dd | ||
|
|
1f0cf816d5 | ||
|
|
6f2df053f4 | ||
|
|
ffc3336367 | ||
|
|
5f4b28a9f7 | ||
|
|
5c96bce8d3 | ||
|
|
2f46bb3ca4 | ||
|
|
4b7f2f5230 | ||
|
|
5a3e5dd739 | ||
|
|
eb8ec111b3 | ||
|
|
8c177f7388 | ||
|
|
55013146ae |
No files matched your search
@@ -33,7 +33,7 @@ jobs:
|
||||
working-directory: libs/sdk-py
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
cache-suffix: test-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
cache-suffix: "test-langgraph"
|
||||
working-directory: libs/langgraph
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: test-dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
@@ -76,13 +76,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: test-dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Publish to test PyPI
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
|
||||
with:
|
||||
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||
verbose: true
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Run check_sdk_methods script
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
@@ -259,13 +259,13 @@ jobs:
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
||||
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
|
||||
with:
|
||||
packages-dir: ${{ inputs.working-directory }}/dist/
|
||||
verbose: true
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
enable-cache: false
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
with:
|
||||
app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
Generated
+45
-45
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
@@ -686,7 +686,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -697,23 +697,23 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -809,27 +809,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
version = "0.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -897,27 +897,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.84"
|
||||
version = "0.0.94"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/c7/1257c8c70e46274664db1eaf2b8aba99a1277470daaa39c65e7decd67c8f/ty-0.0.94.tar.gz", hash = "sha256:933d2feca6057742d04af00c090621c139e7be2c059aa38b040a70532fd5104b", size = 8106769, upload-time = "2026-10-28T18:06:09.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/bd/5145d91cf196cb20fc3b005515b21266a419ea7336d3d7891a1769439259/ty-0.0.94-py3-none-linux_armv6l.whl", hash = "sha256:c6463c045880ad17df911a53d367eb2554b17538ca54911bea6ccb20ec95ee11", size = 14528574, upload-time = "2026-10-28T18:05:19.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/0e/2b563132429b5fc6044095061fde4584548cb55abf0d6d9f2829c41ab226/ty-0.0.94-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec3a3892f4290306c674586149668097cf44f13e8b381ba388662f5a440b459b", size = 14067146, upload-time = "2026-10-28T18:05:23.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/3a458362ea33ca9756cd6b914f905b3dec007d329b21d83e859ecee65a63/ty-0.0.94-py3-none-macosx_11_0_arm64.whl", hash = "sha256:74ddc6eeecd3251cf8980c9abb960d7eeab649dac5cdb7504910d99c479f84ab", size = 13148266, upload-time = "2026-10-28T18:05:25.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/38/5380bdaad2745a4e191deacce96403af74d1992ed8782d5766c301c32b50/ty-0.0.94-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31bdc3aace142400d7e22b4d32676c063ec42f0437d77940012c3dc60587ecdd", size = 13973583, upload-time = "2026-10-28T18:05:28.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/bc/7e1dc4f2594010aff6d1e2e34d0ff9003736210983be5720a04d69c27e03/ty-0.0.94-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fecacfe9c41ed1109aa503ed172d84b1b0fc283ee49aaf078d298dbe1c62e75f", size = 14269235, upload-time = "2026-10-28T18:05:31.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/43041f1c53164d6be21ace55047feffeaa8a9709782b01b06c951412b900/ty-0.0.94-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a49762bbc97a732847da8350ad0227c99ce2675fa32e3f58aa9f9ff4a81e4c14", size = 15191720, upload-time = "2026-10-28T18:05:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/72/f425d48b13cbf12b92e4f837a5866e9788e9ef4d5a071e2b12f54ee94da4/ty-0.0.94-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76a7b3aea839dfafb4bc068af918e0292b06ac26ea2a43f7ab3dd65548c0ed50", size = 15596604, upload-time = "2026-10-28T18:05:37.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/ea/bd2da666ad4af596886833b095098fa9ff93685285f48bebc4d3865126e0/ty-0.0.94-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f032ce5a9c0c7033a5a8e748f952ece2e22afe2b148d211d05dca2cba8b28000", size = 15572711, upload-time = "2026-10-28T18:05:40.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/f8/4f07eac57e9b60f49cc222d5048d9a61ac7f0902964ec8ac8d4ea8bc521d/ty-0.0.94-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc5e780c2107c1b83a21a394076da328e05a293ce06298df40ecab1ddc31d371", size = 16309102, upload-time = "2026-10-28T18:05:43.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/39/c2ac7e14eb8ca59bd3c71b8c3055294524df84c476ba88545f59c11ceaf9/ty-0.0.94-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5bc969cdabf85aa66987c9b4818072e3af17ce5f2b7bc93550ab896c904c6c40", size = 15073645, upload-time = "2026-10-28T18:05:46.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/7d/dd4935abf50a97ccc1f46cf6c921a7acdab30f102c3c56aa4e8d6cbc3fa9/ty-0.0.94-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:27bbaff0d7bbcb3e92ed7cab07d6b26dc7ed7fa28ff69910324e5cc347c7b256", size = 13936242, upload-time = "2026-10-28T18:05:49.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f0/b65fa6ccde2d629e25031bf2cc2d21ac7b50ee6e0e3d29a5f471c4686c54/ty-0.0.94-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d0960eb71220300655d67db91073fd180f385186b1d186e76faa2fd3ae5bd10f", size = 14280351, upload-time = "2026-10-28T18:05:52.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d2/5c901ad5e4f5444852b002b1f4dd3a0b470039305c688facc0483bfccd1a/ty-0.0.94-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab04c14bca92ab92106ceaa19a962a80998389700cf7938576ba3936a304355d", size = 14611163, upload-time = "2026-10-28T18:05:55.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/99/37d8633e9b5d1dcf1b06f12466825ff0c4028664994e573c352546ee9cbd/ty-0.0.94-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a92ebefa092e6bd58cb5719626858df744575473e2de0cd1c44abd44b9b84b57", size = 14938978, upload-time = "2026-10-28T18:05:58.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/13/804b1f993bf84a819385f8b440fbc4b894499e2c20d865cd74d69327e7b8/ty-0.0.94-py3-none-win32.whl", hash = "sha256:3212dcf30b7567f2bb65a83cdfc4818a5385f82e7afddd83548b0d84e759057e", size = 13448009, upload-time = "2026-10-28T18:06:01.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/23/81850750cda0ea781ba9d6e08cea1a1039361cb3474c7dd03765bbc4b51f/ty-0.0.94-py3-none-win_amd64.whl", hash = "sha256:daf5bafe8e7d21b8a99a56fc8518f2342a2dbcaa370a5c25cfaedc684088d158", size = 14010576, upload-time = "2026-10-28T18:06:03.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/ac3a759556493230823abd31e149c95401f96b9cac36567bd403a8454ea3/ty-0.0.94-py3-none-win_arm64.whl", hash = "sha256:ce344353ccdc542cfcfd6abe1481cf23c005f483411ea79652b78ce0beae36f2", size = 13547398, upload-time = "2026-10-28T18:06:06.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=4.3.0,<5.0.0",
|
||||
"langgraph-checkpoint>=4.3.1,<5.0.0",
|
||||
"aiosqlite>=0.20",
|
||||
"sqlite-vec>=0.1.6",
|
||||
]
|
||||
|
||||
Generated
+1
-1
@@ -360,7 +360,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
|
||||
from typing import (
|
||||
@@ -26,6 +27,8 @@ from langgraph.checkpoint.serde.types import (
|
||||
RESUME,
|
||||
SCHEDULED,
|
||||
ChannelProtocol,
|
||||
LogAppend,
|
||||
LogStep,
|
||||
)
|
||||
|
||||
V = TypeVar("V", int, float, str)
|
||||
@@ -182,6 +185,93 @@ class DeltaChannelHistory(TypedDict):
|
||||
seed: NotRequired[Any]
|
||||
|
||||
|
||||
class LogPage(NamedTuple):
|
||||
"""A page of a log channel, as returned by `BaseCheckpointSaver.read_log`.
|
||||
|
||||
!!! warning "Prototype"
|
||||
|
||||
Part of the transcript log prototype. Shape and semantics may change.
|
||||
"""
|
||||
|
||||
entries: list[tuple[int, Any]]
|
||||
"""`(position, entry)` pairs in path order. Entries are JSON values."""
|
||||
length: int
|
||||
"""Number of committed entries on the path to the checkpoint's leaf."""
|
||||
pending: list[Any]
|
||||
"""Entries written by finished tasks of the step in progress at the head,
|
||||
not committed yet. Empty unless the checkpoint read is the thread's latest."""
|
||||
|
||||
|
||||
def log_range(
|
||||
length: int, after: int | None, before: int | None, limit: int | None
|
||||
) -> tuple[int, int]:
|
||||
"""The position range `[lo, hi)` a `read_log` call asks for.
|
||||
|
||||
`after=p` starts after position `p`; `before=p` stops before it. With a
|
||||
`limit`, a read with only `after` takes the first `limit` entries of the
|
||||
range; any other read takes the last `limit` (chat UIs page from the end).
|
||||
"""
|
||||
lo = 0 if after is None else max(after + 1, 0)
|
||||
hi = length if before is None else min(before, length)
|
||||
if limit is not None:
|
||||
if after is not None and before is None:
|
||||
hi = min(hi, lo + limit)
|
||||
else:
|
||||
lo = max(lo, hi - limit)
|
||||
return lo, max(lo, hi)
|
||||
|
||||
|
||||
def _without_checkpoint_id(config: RunnableConfig) -> RunnableConfig:
|
||||
conf = {k: v for k, v in config["configurable"].items() if k != "checkpoint_id"}
|
||||
return {**config, "configurable": conf}
|
||||
|
||||
|
||||
def _pending_log_entries(tup: CheckpointTuple, channel: str) -> list[Any]:
|
||||
return [
|
||||
json.loads(text)
|
||||
for _, ch, value in tup.pending_writes or ()
|
||||
if ch == channel and isinstance(value, LogAppend)
|
||||
for text in value.entries
|
||||
]
|
||||
|
||||
|
||||
def _step_entries(
|
||||
step: LogStep, parent: CheckpointTuple | None, channel: str
|
||||
) -> list[str]:
|
||||
"""The JSON texts of one step: inline, or the `LogAppend` writes stored on
|
||||
the checkpoint the step started from."""
|
||||
if step.entries is not None:
|
||||
texts = list(step.entries)
|
||||
else:
|
||||
texts = [
|
||||
text
|
||||
for _, ch, value in (parent.pending_writes or () if parent else ())
|
||||
if ch == channel
|
||||
and isinstance(value, LogAppend)
|
||||
and value.start == step.start
|
||||
for text in value.entries
|
||||
]
|
||||
if len(texts) != step.count:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: the step at position {step.start} "
|
||||
f"committed {step.count} entries, found {len(texts)}"
|
||||
)
|
||||
return texts
|
||||
|
||||
|
||||
def _page_entries(
|
||||
steps: Sequence[tuple[int, Sequence[str]]], lo: int, hi: int
|
||||
) -> list[tuple[int, Any]]:
|
||||
"""Positions `[lo, hi)` from steps given as `(start, texts)`, any order."""
|
||||
out: list[tuple[int, Any]] = []
|
||||
for start, texts in sorted(steps, key=lambda s: s[0]):
|
||||
for i, text in enumerate(texts):
|
||||
pos = start + i
|
||||
if lo <= pos < hi:
|
||||
out.append((pos, json.loads(text)))
|
||||
return out
|
||||
|
||||
|
||||
def writes_sort_key(
|
||||
task_path: str, task_id: str = "", idx: int = 0
|
||||
) -> tuple[str, str, int]:
|
||||
@@ -231,6 +321,14 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
|
||||
serde: SerializerProtocol = JsonPlusSerializer()
|
||||
|
||||
supports_log: bool = False
|
||||
"""Whether this saver stores log channels natively (prototype).
|
||||
|
||||
A saver without native support still works: it stores `LogAppend` writes
|
||||
and `LogStep` values like any other, and the default `read_log` walks the
|
||||
parent chain to read them back.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -604,6 +702,109 @@ class BaseCheckpointSaver(Generic[V]):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def read_log(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
channel: str,
|
||||
*,
|
||||
after: int | None = None,
|
||||
before: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> LogPage:
|
||||
"""Read a log channel as of the checkpoint `config` names (or the latest).
|
||||
|
||||
!!! warning "Prototype"
|
||||
|
||||
Part of the transcript log prototype. Shape and semantics may change.
|
||||
|
||||
The log as of a checkpoint is the path from the root to the leaf its
|
||||
channel version points at. See `log_range` for `after`, `before` and
|
||||
`limit`.
|
||||
|
||||
The default implementation walks the parent chain through `get_tuple`:
|
||||
a step's entries are inline in its `LogStep`, or are the `LogAppend`
|
||||
writes stored on the checkpoint the step started from. It is correct
|
||||
and costs one `get_tuple` per checkpoint walked. Savers with direct
|
||||
storage access override it with range reads.
|
||||
"""
|
||||
tup = self.get_tuple(config)
|
||||
if tup is None:
|
||||
return LogPage([], 0, [])
|
||||
latest = self.get_tuple(_without_checkpoint_id(tup.config))
|
||||
is_head = latest is not None and latest.checkpoint["id"] == tup.checkpoint["id"]
|
||||
pending = _pending_log_entries(tup, channel) if is_head else []
|
||||
marker = tup.checkpoint["channel_values"].get(channel)
|
||||
if not isinstance(marker, LogStep):
|
||||
return LogPage([], 0, pending)
|
||||
lo, hi = log_range(marker.end, after, before, limit)
|
||||
steps: list[tuple[int, list[str]]] = []
|
||||
cur: CheckpointTuple | None = tup
|
||||
while cur is not None:
|
||||
version = cur.checkpoint["channel_versions"].get(channel)
|
||||
step = cur.checkpoint["channel_values"].get(channel)
|
||||
if version is None or not isinstance(step, LogStep) or step.end <= lo:
|
||||
break
|
||||
# climb to the checkpoint that committed this version: later ones
|
||||
# carry its marker without inline entries
|
||||
parent = self.get_tuple(cur.parent_config) if cur.parent_config else None
|
||||
while (
|
||||
parent is not None
|
||||
and parent.checkpoint["channel_versions"].get(channel) == version
|
||||
):
|
||||
step = parent.checkpoint["channel_values"].get(channel, step)
|
||||
parent = (
|
||||
self.get_tuple(parent.parent_config)
|
||||
if parent.parent_config
|
||||
else None
|
||||
)
|
||||
steps.append((step.start, _step_entries(step, parent, channel)))
|
||||
cur = parent
|
||||
return LogPage(_page_entries(steps, lo, hi), marker.end, pending)
|
||||
|
||||
async def aread_log(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
channel: str,
|
||||
*,
|
||||
after: int | None = None,
|
||||
before: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> LogPage:
|
||||
"""Async version of `read_log`."""
|
||||
tup = await self.aget_tuple(config)
|
||||
if tup is None:
|
||||
return LogPage([], 0, [])
|
||||
latest = await self.aget_tuple(_without_checkpoint_id(tup.config))
|
||||
is_head = latest is not None and latest.checkpoint["id"] == tup.checkpoint["id"]
|
||||
pending = _pending_log_entries(tup, channel) if is_head else []
|
||||
marker = tup.checkpoint["channel_values"].get(channel)
|
||||
if not isinstance(marker, LogStep):
|
||||
return LogPage([], 0, pending)
|
||||
lo, hi = log_range(marker.end, after, before, limit)
|
||||
steps: list[tuple[int, list[str]]] = []
|
||||
cur: CheckpointTuple | None = tup
|
||||
while cur is not None:
|
||||
version = cur.checkpoint["channel_versions"].get(channel)
|
||||
step = cur.checkpoint["channel_values"].get(channel)
|
||||
if version is None or not isinstance(step, LogStep) or step.end <= lo:
|
||||
break
|
||||
parent = (
|
||||
await self.aget_tuple(cur.parent_config) if cur.parent_config else None
|
||||
)
|
||||
while (
|
||||
parent is not None
|
||||
and parent.checkpoint["channel_versions"].get(channel) == version
|
||||
):
|
||||
step = parent.checkpoint["channel_values"].get(channel, step)
|
||||
parent = (
|
||||
await self.aget_tuple(parent.parent_config)
|
||||
if parent.parent_config
|
||||
else None
|
||||
)
|
||||
steps.append((step.start, _step_entries(step, parent, channel)))
|
||||
cur = parent
|
||||
return LogPage(_page_entries(steps, lo, hi), marker.end, pending)
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
|
||||
@@ -21,12 +21,15 @@ from langgraph.checkpoint.base import (
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
DeltaChannelHistory,
|
||||
LogPage,
|
||||
PendingWrite,
|
||||
SerializerProtocol,
|
||||
get_checkpoint_id,
|
||||
get_checkpoint_metadata,
|
||||
writes_sort_key,
|
||||
)
|
||||
from langgraph.checkpoint.memory._log import InMemoryLog
|
||||
from langgraph.checkpoint.serde.types import LogAppend, LogStep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,6 +69,8 @@ class InMemorySaver(
|
||||
```
|
||||
"""
|
||||
|
||||
supports_log = True
|
||||
|
||||
# thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping
|
||||
storage: defaultdict[
|
||||
str,
|
||||
@@ -93,6 +98,7 @@ class InMemorySaver(
|
||||
self.storage = factory(lambda: defaultdict(dict))
|
||||
self.writes = factory(dict)
|
||||
self.blobs = factory()
|
||||
self.log = InMemoryLog()
|
||||
self.stack = ExitStack()
|
||||
if factory is not defaultdict:
|
||||
self.stack.enter_context(self.storage) # type: ignore[arg-type]
|
||||
@@ -131,6 +137,11 @@ class InMemorySaver(
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for k, ver in versions.items():
|
||||
if (
|
||||
marker := self.log.marker(thread_id, checkpoint_ns, k, ver)
|
||||
) is not None:
|
||||
result[k] = marker
|
||||
continue
|
||||
kk = (thread_id, checkpoint_ns, k, ver)
|
||||
if kk not in self.blobs:
|
||||
continue
|
||||
@@ -149,6 +160,87 @@ class InMemorySaver(
|
||||
for k in sorted(stored, key=lambda k: writes_sort_key(stored[k][3], *k))
|
||||
]
|
||||
|
||||
def _pending_writes(
|
||||
self, thread_id: str, checkpoint_ns: str, checkpoint_id: str
|
||||
) -> list[PendingWrite]:
|
||||
"""A checkpoint's writes, including its uncommitted log writes, in
|
||||
`writes_sort_key` order."""
|
||||
stored = self.writes.get((thread_id, checkpoint_ns, checkpoint_id), {})
|
||||
rows: list[tuple[tuple[str, str, int], PendingWrite]] = [
|
||||
(
|
||||
writes_sort_key(v[3], *k),
|
||||
(v[0], v[1], self.serde.loads_typed(v[2])),
|
||||
)
|
||||
for k, v in stored.items()
|
||||
]
|
||||
for task_path, task_id, channel, idx, value in self.log.pending_writes(
|
||||
thread_id, checkpoint_ns, checkpoint_id
|
||||
):
|
||||
rows.append(
|
||||
(writes_sort_key(task_path, task_id, idx), (task_id, channel, value))
|
||||
)
|
||||
rows.sort(key=lambda r: r[0])
|
||||
return [w for _, w in rows]
|
||||
|
||||
def _version(
|
||||
self,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
checkpoint_id: str | None,
|
||||
channel: str,
|
||||
) -> Any:
|
||||
if checkpoint_id is None:
|
||||
return None
|
||||
saved = (
|
||||
self.storage.get(thread_id, {}).get(checkpoint_ns, {}).get(checkpoint_id)
|
||||
)
|
||||
if saved is None:
|
||||
return None
|
||||
version = self.serde.loads_typed(saved[0])["channel_versions"].get(channel)
|
||||
if (thread_id, checkpoint_ns, channel, version) in self.blobs:
|
||||
# stored before the field became a log channel: its log is empty
|
||||
return None
|
||||
return version
|
||||
|
||||
def read_log(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
channel: str,
|
||||
*,
|
||||
after: int | None = None,
|
||||
before: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> LogPage:
|
||||
"""Read a log channel with range reads over its segment chain."""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
|
||||
latest = max(ns_storage, default=None)
|
||||
checkpoint_id = get_checkpoint_id(config) or latest
|
||||
if checkpoint_id is None or checkpoint_id not in ns_storage:
|
||||
return LogPage([], 0, [])
|
||||
return self.log.read(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
channel,
|
||||
self._version(thread_id, checkpoint_ns, checkpoint_id, channel),
|
||||
checkpoint_id if checkpoint_id == latest else None,
|
||||
after=after,
|
||||
before=before,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def aread_log(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
channel: str,
|
||||
*,
|
||||
after: int | None = None,
|
||||
before: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> LogPage:
|
||||
return self.read_log(config, channel, after=after, before=before, limit=limit)
|
||||
|
||||
def get_delta_channel_history(
|
||||
self, *, config: RunnableConfig, channels: Sequence[str]
|
||||
) -> Mapping[str, DeltaChannelHistory]:
|
||||
@@ -255,7 +347,6 @@ class InMemorySaver(
|
||||
if checkpoint_id := get_checkpoint_id(config):
|
||||
if saved := self.storage[thread_id][checkpoint_ns].get(checkpoint_id):
|
||||
checkpoint, metadata, parent_checkpoint_id = saved
|
||||
writes = self._ordered_writes(thread_id, checkpoint_ns, checkpoint_id)
|
||||
checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint)
|
||||
return CheckpointTuple(
|
||||
config=config,
|
||||
@@ -266,9 +357,9 @@ class InMemorySaver(
|
||||
),
|
||||
},
|
||||
metadata=self.serde.loads_typed(metadata),
|
||||
pending_writes=[
|
||||
(id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes
|
||||
],
|
||||
pending_writes=self._pending_writes(
|
||||
thread_id, checkpoint_ns, checkpoint_id
|
||||
),
|
||||
parent_config=(
|
||||
{
|
||||
"configurable": {
|
||||
@@ -285,7 +376,6 @@ class InMemorySaver(
|
||||
if checkpoints := self.storage[thread_id][checkpoint_ns]:
|
||||
checkpoint_id = max(checkpoints.keys())
|
||||
checkpoint, metadata, parent_checkpoint_id = checkpoints[checkpoint_id]
|
||||
writes = self._ordered_writes(thread_id, checkpoint_ns, checkpoint_id)
|
||||
checkpoint_ = self.serde.loads_typed(checkpoint)
|
||||
return CheckpointTuple(
|
||||
config={
|
||||
@@ -302,9 +392,9 @@ class InMemorySaver(
|
||||
),
|
||||
},
|
||||
metadata=self.serde.loads_typed(metadata),
|
||||
pending_writes=[
|
||||
(id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes
|
||||
],
|
||||
pending_writes=self._pending_writes(
|
||||
thread_id, checkpoint_ns, checkpoint_id
|
||||
),
|
||||
parent_config=(
|
||||
{
|
||||
"configurable": {
|
||||
@@ -388,10 +478,6 @@ class InMemorySaver(
|
||||
elif limit is not None:
|
||||
limit -= 1
|
||||
|
||||
writes = self._ordered_writes(
|
||||
thread_id, checkpoint_ns, checkpoint_id
|
||||
)
|
||||
|
||||
checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint)
|
||||
|
||||
yield CheckpointTuple(
|
||||
@@ -422,9 +508,9 @@ class InMemorySaver(
|
||||
if parent_checkpoint_id
|
||||
else None
|
||||
),
|
||||
pending_writes=[
|
||||
(id, c, self.serde.loads_typed(v)) for id, c, v, _ in writes
|
||||
],
|
||||
pending_writes=self._pending_writes(
|
||||
thread_id, checkpoint_ns, checkpoint_id
|
||||
),
|
||||
)
|
||||
|
||||
def put(
|
||||
@@ -451,8 +537,22 @@ class InMemorySaver(
|
||||
c = checkpoint.copy()
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"]["checkpoint_ns"]
|
||||
parent_id = config["configurable"].get("checkpoint_id")
|
||||
values: dict[str, Any] = c.pop("channel_values") # type: ignore[misc]
|
||||
for k, v in new_versions.items():
|
||||
if isinstance(values.get(k), LogStep):
|
||||
# a log channel: the step row is its "blob" at this version
|
||||
self.log.put_step(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
k,
|
||||
self._version(thread_id, checkpoint_ns, parent_id, k),
|
||||
parent_id,
|
||||
checkpoint["id"],
|
||||
v,
|
||||
values[k],
|
||||
)
|
||||
continue
|
||||
self.blobs[(thread_id, checkpoint_ns, k, v)] = (
|
||||
self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
|
||||
)
|
||||
@@ -500,6 +600,19 @@ class InMemorySaver(
|
||||
outer_key = (thread_id, checkpoint_ns, checkpoint_id)
|
||||
outer_writes_ = self.writes.get(outer_key)
|
||||
for idx, (c, v) in enumerate(writes):
|
||||
if isinstance(v, LogAppend):
|
||||
self.log.put_append(
|
||||
thread_id,
|
||||
checkpoint_ns,
|
||||
c,
|
||||
self._version(thread_id, checkpoint_ns, checkpoint_id, c),
|
||||
checkpoint_id,
|
||||
task_id,
|
||||
task_path,
|
||||
idx,
|
||||
v,
|
||||
)
|
||||
continue
|
||||
inner_key = (task_id, WRITES_IDX_MAP.get(c, idx))
|
||||
if inner_key[1] >= 0 and outer_writes_ and inner_key in outer_writes_:
|
||||
continue
|
||||
@@ -528,6 +641,72 @@ class InMemorySaver(
|
||||
for k in list(self.blobs.keys()):
|
||||
if k[0] == thread_id:
|
||||
del self.blobs[k]
|
||||
self.log.delete_thread(thread_id)
|
||||
|
||||
def copy_thread(self, source_thread_id: str, target_thread_id: str) -> None:
|
||||
"""Copy all checkpoints, writes, blobs and log rows to another thread."""
|
||||
for ns, checkpoints in self.storage.get(source_thread_id, {}).items():
|
||||
self.storage[target_thread_id][ns] = dict(checkpoints)
|
||||
for (t, ns, cid), rows in list(self.writes.items()):
|
||||
if t == source_thread_id:
|
||||
self.writes[(target_thread_id, ns, cid)] = dict(rows)
|
||||
for (t, ns, ch, ver), blob in list(self.blobs.items()):
|
||||
if t == source_thread_id:
|
||||
self.blobs[(target_thread_id, ns, ch, ver)] = blob
|
||||
self.log.copy_thread(source_thread_id, target_thread_id)
|
||||
|
||||
def prune(
|
||||
self, thread_ids: Sequence[str], *, strategy: str = "keep_latest"
|
||||
) -> None:
|
||||
"""Prune checkpoints; the log keeps what remaining checkpoints reach.
|
||||
|
||||
`"keep_latest"` keeps the latest checkpoint per namespace, `"delete"`
|
||||
deletes every checkpoint. Writes and blobs no remaining checkpoint
|
||||
uses are deleted with them.
|
||||
"""
|
||||
if strategy not in ("keep_latest", "delete"):
|
||||
raise ValueError(f"Unknown prune strategy: {strategy}")
|
||||
for thread_id in thread_ids:
|
||||
keep = {
|
||||
ns: ({max(cps)} if strategy == "keep_latest" and cps else set())
|
||||
for ns, cps in self.storage.get(thread_id, {}).items()
|
||||
}
|
||||
self._retain(thread_id, keep)
|
||||
|
||||
def delete_for_runs(self, run_ids: Sequence[str]) -> None:
|
||||
"""Delete the checkpoints the given runs created; the log keeps what
|
||||
the remaining checkpoints reach."""
|
||||
runs = set(run_ids)
|
||||
for thread_id, namespaces in list(self.storage.items()):
|
||||
keep = {
|
||||
ns: {
|
||||
cid
|
||||
for cid, saved in cps.items()
|
||||
if self.serde.loads_typed(saved[1]).get("run_id") not in runs
|
||||
}
|
||||
for ns, cps in namespaces.items()
|
||||
}
|
||||
self._retain(thread_id, keep)
|
||||
|
||||
def _retain(self, thread_id: str, keep: dict[str, set[str]]) -> None:
|
||||
namespaces = self.storage.get(thread_id, {})
|
||||
for ns, cps in list(namespaces.items()):
|
||||
for cid in [c for c in cps if c not in keep.get(ns, set())]:
|
||||
del cps[cid]
|
||||
self.writes.pop((thread_id, ns, cid), None)
|
||||
used = {
|
||||
(thread_id, ns, ch, ver)
|
||||
for ns, cps in namespaces.items()
|
||||
for saved in cps.values()
|
||||
for ch, ver in self.serde.loads_typed(saved[0])["channel_versions"].items()
|
||||
}
|
||||
for k in [k for k in self.blobs if k[0] == thread_id and k not in used]:
|
||||
del self.blobs[k]
|
||||
self.log.collect(
|
||||
thread_id,
|
||||
{ns: dict.fromkeys(cps) for ns, cps in namespaces.items()},
|
||||
lambda ns, cid, ch: self._version(thread_id, ns, cid, ch),
|
||||
)
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
||||
"""Asynchronous version of `get_tuple`.
|
||||
@@ -619,6 +798,17 @@ class InMemorySaver(
|
||||
"""
|
||||
return self.delete_thread(thread_id)
|
||||
|
||||
async def acopy_thread(self, source_thread_id: str, target_thread_id: str) -> None:
|
||||
return self.copy_thread(source_thread_id, target_thread_id)
|
||||
|
||||
async def aprune(
|
||||
self, thread_ids: Sequence[str], *, strategy: str = "keep_latest"
|
||||
) -> None:
|
||||
return self.prune(thread_ids, strategy=strategy)
|
||||
|
||||
async def adelete_for_runs(self, run_ids: Sequence[str]) -> None:
|
||||
return self.delete_for_runs(run_ids)
|
||||
|
||||
def get_next_version(self, current: str | None, channel: None) -> str:
|
||||
if current is None:
|
||||
current_v = 0
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Log channel storage for `InMemorySaver` (transcript log prototype).
|
||||
|
||||
The reference implementation of the log storage design: entries stored once,
|
||||
keyed in path order; one step row per committing channel version; segments
|
||||
for linear runs, with parent pointers between them. Reads are range reads
|
||||
over a segment chain, and lifecycle is reachability from the checkpoints that
|
||||
remain.
|
||||
|
||||
Rows, per `(thread_id, checkpoint_ns, channel)`:
|
||||
|
||||
- segment: `seg -> (parent_seg | None, fork_pos)`
|
||||
- step: `version -> StepRow(seg, start, count, batch)`, unique on `(seg, start)`
|
||||
- entry: `seg -> {(start, batch, task_path, task_id, idx, j): json_text}`
|
||||
|
||||
`batch` names the execution that wrote a step's entries: the checkpoint the
|
||||
step started from for entries that came through `put_writes`, the committing
|
||||
checkpoint for entries a `LogStep` carried inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import LogPage, log_range, writes_sort_key
|
||||
from langgraph.checkpoint.serde.types import LogAppend, LogStep
|
||||
|
||||
_SEGMENT_NS = uuid.UUID("6f6c6f67-0000-4000-8000-6c6f67736567")
|
||||
|
||||
EntryKey = tuple[int, str, str, str, int, int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepRow:
|
||||
seg: str
|
||||
start: int
|
||||
count: int
|
||||
batch: str
|
||||
exec: str
|
||||
|
||||
@property
|
||||
def end(self) -> int:
|
||||
return self.start + self.count
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelLog:
|
||||
segments: dict[str, tuple[str | None, int]] = field(default_factory=dict)
|
||||
steps: dict[str, StepRow] = field(default_factory=dict)
|
||||
step_at: dict[tuple[str, int], str] = field(default_factory=dict)
|
||||
entries: defaultdict[str, dict[EntryKey, str]] = field(
|
||||
default_factory=lambda: defaultdict(dict)
|
||||
)
|
||||
batch_segments: defaultdict[str, list[str]] = field(
|
||||
default_factory=lambda: defaultdict(list)
|
||||
)
|
||||
|
||||
def copy(self) -> ChannelLog:
|
||||
new = ChannelLog()
|
||||
new.segments = dict(self.segments)
|
||||
new.steps = {
|
||||
v: StepRow(r.seg, r.start, r.count, r.batch, r.exec)
|
||||
for v, r in self.steps.items()
|
||||
}
|
||||
new.step_at = dict(self.step_at)
|
||||
for seg, rows in self.entries.items():
|
||||
new.entries[seg] = dict(rows)
|
||||
for batch, segs in self.batch_segments.items():
|
||||
new.batch_segments[batch] = list(segs)
|
||||
return new
|
||||
|
||||
# -- structure ---------------------------------------------------------
|
||||
|
||||
def committed(self, seg: str) -> bool:
|
||||
return any(s == seg for s, _ in self.step_at)
|
||||
|
||||
def tip(self, seg: str) -> int:
|
||||
ends = [self.steps[v].end for (s, _), v in self.step_at.items() if s == seg]
|
||||
return max(ends) if ends else self.segments[seg][1]
|
||||
|
||||
def chain(self, seg: str, upto: int) -> list[tuple[str, int]]:
|
||||
"""`(segment, upto)` pairs from the leaf's segment to the root."""
|
||||
out: list[tuple[str, int]] = []
|
||||
cur: str | None = seg
|
||||
while cur is not None:
|
||||
out.append((cur, upto))
|
||||
cur, upto = self.segments[cur]
|
||||
return out
|
||||
|
||||
def segment_for(self, leaf: tuple[str | None, int], batch: str, exec_: str) -> str:
|
||||
"""The fork rule: continue the leaf's segment if the leaf is its tip;
|
||||
otherwise use a new segment forking at the leaf, reusing one this
|
||||
batch already started and nothing committed to."""
|
||||
seg, end = leaf
|
||||
if seg is not None and self.tip(seg) == end:
|
||||
return seg
|
||||
for cand in self.batch_segments.get(batch, ()):
|
||||
if self.segments[cand] == leaf and not self.committed(cand):
|
||||
return cand
|
||||
new = str(
|
||||
uuid.uuid5(
|
||||
_SEGMENT_NS, f"{batch}:{exec_}:{len(self.batch_segments[batch])}"
|
||||
)
|
||||
)
|
||||
self.segments[new] = leaf
|
||||
self.batch_segments[batch].append(new)
|
||||
return new
|
||||
|
||||
def step_texts(self, seg: str, start: int, batch: str) -> list[str]:
|
||||
rows = self.entries.get(seg, {})
|
||||
keys = sorted(
|
||||
(k for k in rows if k[0] == start and k[1] == batch),
|
||||
key=lambda k: (writes_sort_key(k[2], k[3], k[4]), k[5]),
|
||||
)
|
||||
return [rows[k] for k in keys]
|
||||
|
||||
def replace_write(
|
||||
self,
|
||||
seg: str,
|
||||
start: int,
|
||||
batch: str,
|
||||
task_path: str,
|
||||
task_id: str,
|
||||
idx: int,
|
||||
texts: Iterable[str],
|
||||
) -> None:
|
||||
rows = self.entries[seg]
|
||||
for k in [k for k in rows if k[:5] == (start, batch, task_path, task_id, idx)]:
|
||||
del rows[k]
|
||||
for j, text in enumerate(texts):
|
||||
rows[(start, batch, task_path, task_id, idx, j)] = text
|
||||
|
||||
def is_committed_step(self, seg: str, start: int, batch: str) -> bool:
|
||||
v = self.step_at.get((seg, start))
|
||||
return v is not None and self.steps[v].batch == batch
|
||||
|
||||
def pending(self, batch: str) -> list[tuple[str, str, int, LogAppend, str]]:
|
||||
"""Uncommitted writes stored under `batch`, as
|
||||
`(task_path, task_id, idx, LogAppend, exec)`, in write order."""
|
||||
groups: dict[tuple[str, int, str, str, int], list[tuple[int, str]]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
for seg, rows in self.entries.items():
|
||||
for (start, b, task_path, task_id, idx, j), text in rows.items():
|
||||
if b != batch or self.is_committed_step(seg, start, b):
|
||||
continue
|
||||
groups[(seg, start, task_path, task_id, idx)].append((j, text))
|
||||
out = []
|
||||
for (_, start, task_path, task_id, idx), items in groups.items():
|
||||
texts = tuple(t for _, t in sorted(items))
|
||||
out.append((task_path, task_id, idx, LogAppend(start, texts), ""))
|
||||
out.sort(key=lambda w: writes_sort_key(w[0], w[1], w[2]))
|
||||
return out
|
||||
|
||||
|
||||
class InMemoryLog:
|
||||
"""Log storage for one `InMemorySaver`."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.logs: dict[tuple[str, str, str], ChannelLog] = {}
|
||||
self.lock = threading.RLock()
|
||||
|
||||
def _log(self, thread_id: str, ns: str, channel: str) -> ChannelLog:
|
||||
key = (thread_id, ns, channel)
|
||||
if key not in self.logs:
|
||||
self.logs[key] = ChannelLog()
|
||||
return self.logs[key]
|
||||
|
||||
def is_log_version(
|
||||
self, thread_id: str, ns: str, channel: str, version: Any
|
||||
) -> bool:
|
||||
log = self.logs.get((thread_id, ns, channel))
|
||||
return log is not None and version in log.steps
|
||||
|
||||
def marker(
|
||||
self, thread_id: str, ns: str, channel: str, version: Any
|
||||
) -> LogStep | None:
|
||||
log = self.logs.get((thread_id, ns, channel))
|
||||
if log is None or version not in log.steps:
|
||||
return None
|
||||
row = log.steps[version]
|
||||
return LogStep(row.start, row.count, row.exec)
|
||||
|
||||
def leaf(
|
||||
self, thread_id: str, ns: str, channel: str, version: Any
|
||||
) -> tuple[str | None, int]:
|
||||
if version is None:
|
||||
return (None, 0)
|
||||
log = self.logs.get((thread_id, ns, channel))
|
||||
if log is None or version not in log.steps:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: version {version} has no step row"
|
||||
)
|
||||
row = log.steps[version]
|
||||
return (row.seg, row.end)
|
||||
|
||||
# -- writes ------------------------------------------------------------
|
||||
|
||||
def put_append(
|
||||
self,
|
||||
thread_id: str,
|
||||
ns: str,
|
||||
channel: str,
|
||||
parent_version: Any,
|
||||
batch: str,
|
||||
task_id: str,
|
||||
task_path: str,
|
||||
idx: int,
|
||||
value: LogAppend,
|
||||
) -> None:
|
||||
with self.lock:
|
||||
log = self._log(thread_id, ns, channel)
|
||||
leaf = self.leaf(thread_id, ns, channel, parent_version)
|
||||
if value.start != leaf[1]:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: append at {value.start}, but the "
|
||||
f"checkpoint's log has {leaf[1]} entries"
|
||||
)
|
||||
seg = log.segment_for(leaf, batch, value.exec)
|
||||
if log.is_committed_step(seg, value.start, batch):
|
||||
return # already committed: a reused write, nothing to do
|
||||
log.replace_write(
|
||||
seg, value.start, batch, task_path, task_id, idx, value.entries
|
||||
)
|
||||
|
||||
def put_step(
|
||||
self,
|
||||
thread_id: str,
|
||||
ns: str,
|
||||
channel: str,
|
||||
parent_version: Any,
|
||||
parent_id: str | None,
|
||||
checkpoint_id: str,
|
||||
version: Any,
|
||||
step: LogStep,
|
||||
) -> None:
|
||||
with self.lock:
|
||||
log = self._log(thread_id, ns, channel)
|
||||
leaf = self.leaf(thread_id, ns, channel, parent_version)
|
||||
if step.start != leaf[1]:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: step starts at {step.start}, but "
|
||||
f"the parent checkpoint's log has {leaf[1]} entries"
|
||||
)
|
||||
inline = step.entries is not None
|
||||
batch = checkpoint_id if inline or parent_id is None else parent_id
|
||||
seg = log.segment_for(leaf, batch, step.exec)
|
||||
if inline:
|
||||
log.replace_write(seg, step.start, batch, "", "", 0, step.entries)
|
||||
texts = log.step_texts(seg, step.start, batch)
|
||||
if len(texts) != step.count:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: the step at {step.start} commits "
|
||||
f"{step.count} entries, but {len(texts)} were written"
|
||||
)
|
||||
existing = log.step_at.get((seg, step.start))
|
||||
if existing is not None and existing != version:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: position {step.start} of segment "
|
||||
f"{seg} was already committed by another step (concurrent "
|
||||
"writers on one checkpoint)"
|
||||
)
|
||||
log.steps[version] = StepRow(seg, step.start, step.count, batch, step.exec)
|
||||
log.step_at[(seg, step.start)] = version
|
||||
|
||||
# -- reads -------------------------------------------------------------
|
||||
|
||||
def read(
|
||||
self,
|
||||
thread_id: str,
|
||||
ns: str,
|
||||
channel: str,
|
||||
version: Any,
|
||||
batch: str | None,
|
||||
*,
|
||||
after: int | None,
|
||||
before: int | None,
|
||||
limit: int | None,
|
||||
) -> LogPage:
|
||||
with self.lock:
|
||||
log = self.logs.get((thread_id, ns, channel))
|
||||
pending: list[Any] = []
|
||||
if log is not None and batch is not None:
|
||||
pending = [
|
||||
json.loads(text)
|
||||
for *_, value, _ in log.pending(batch)
|
||||
for text in value.entries
|
||||
]
|
||||
if log is None or version is None or version not in log.steps:
|
||||
return LogPage([], 0, pending)
|
||||
row = log.steps[version]
|
||||
lo, hi = log_range(row.end, after, before, limit)
|
||||
out: list[tuple[int, Any]] = []
|
||||
for seg, upto in log.chain(row.seg, row.end):
|
||||
if upto <= lo:
|
||||
break
|
||||
starts = sorted(s for (sg, s) in log.step_at if sg == seg and s < upto)
|
||||
for start in starts:
|
||||
srow = log.steps[log.step_at[(seg, start)]]
|
||||
if srow.end <= lo or srow.start >= hi:
|
||||
continue
|
||||
texts = log.step_texts(seg, start, srow.batch)
|
||||
if len(texts) != srow.count:
|
||||
raise ValueError(
|
||||
f"log channel {channel!r}: step at {start} has "
|
||||
f"{len(texts)} entries, expected {srow.count}"
|
||||
)
|
||||
for i, text in enumerate(texts):
|
||||
if lo <= start + i < hi:
|
||||
out.append((start + i, json.loads(text)))
|
||||
out.sort(key=lambda e: e[0])
|
||||
return LogPage(out, row.end, pending)
|
||||
|
||||
def pending_writes(
|
||||
self, thread_id: str, ns: str, checkpoint_id: str
|
||||
) -> list[tuple[str, str, str, int, LogAppend]]:
|
||||
"""Uncommitted log writes stored on a checkpoint, as
|
||||
`(task_path, task_id, channel, idx, LogAppend)`."""
|
||||
with self.lock:
|
||||
out = []
|
||||
for (t, n, channel), log in self.logs.items():
|
||||
if t != thread_id or n != ns:
|
||||
continue
|
||||
for task_path, task_id, idx, value, _ in log.pending(checkpoint_id):
|
||||
out.append((task_path, task_id, channel, idx, value))
|
||||
return out
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def delete_thread(self, thread_id: str) -> None:
|
||||
with self.lock:
|
||||
for key in [k for k in self.logs if k[0] == thread_id]:
|
||||
del self.logs[key]
|
||||
|
||||
def copy_thread(self, source: str, target: str) -> None:
|
||||
with self.lock:
|
||||
for (t, ns, channel), log in list(self.logs.items()):
|
||||
if t == source:
|
||||
self.logs[(target, ns, channel)] = log.copy()
|
||||
|
||||
def collect(
|
||||
self,
|
||||
thread_id: str,
|
||||
remaining: dict[str, dict[str, Any]],
|
||||
version_of: Callable[[str, str, str], Any],
|
||||
) -> None:
|
||||
"""Keep exactly what remaining checkpoints reach.
|
||||
|
||||
`remaining` maps each namespace to its remaining checkpoint ids;
|
||||
`version_of(ns, checkpoint_id, channel)` returns a checkpoint's
|
||||
version of a channel.
|
||||
"""
|
||||
with self.lock:
|
||||
for (t, ns, channel), log in list(self.logs.items()):
|
||||
if t != thread_id:
|
||||
continue
|
||||
cids = remaining.get(ns, {})
|
||||
reach: dict[str, int] = {}
|
||||
for cid in cids:
|
||||
version = version_of(ns, cid, channel)
|
||||
if version is None or version not in log.steps:
|
||||
continue
|
||||
row = log.steps[version]
|
||||
for seg, upto in log.chain(row.seg, row.end):
|
||||
reach[seg] = max(reach.get(seg, -1), upto)
|
||||
kept_steps = {
|
||||
v: r
|
||||
for v, r in log.steps.items()
|
||||
if r.seg in reach and r.start < reach[r.seg]
|
||||
}
|
||||
kept_keys = {(r.seg, r.start, r.batch) for r in kept_steps.values()}
|
||||
for seg in list(log.entries):
|
||||
rows = log.entries[seg]
|
||||
for k in list(rows):
|
||||
start, batch = k[0], k[1]
|
||||
if (seg, start, batch) in kept_keys:
|
||||
continue
|
||||
if batch in cids and not log.is_committed_step(
|
||||
seg, start, batch
|
||||
):
|
||||
continue # pending at a remaining checkpoint
|
||||
del rows[k]
|
||||
if not rows:
|
||||
del log.entries[seg]
|
||||
log.steps = kept_steps
|
||||
log.step_at = {(r.seg, r.start): v for v, r in kept_steps.items()}
|
||||
live = set(reach) | set(log.entries)
|
||||
log.segments = {s: p for s, p in log.segments.items() if s in live}
|
||||
for batch in list(log.batch_segments):
|
||||
segs = [s for s in log.batch_segments[batch] if s in live]
|
||||
if segs:
|
||||
log.batch_segments[batch] = segs
|
||||
else:
|
||||
del log.batch_segments[batch]
|
||||
if not log.steps and not log.entries:
|
||||
del self.logs[(t, ns, channel)]
|
||||
@@ -34,6 +34,8 @@ from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
from langgraph.checkpoint.serde.event_hooks import emit_serde_event
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
LogAppend,
|
||||
LogStep,
|
||||
SendProtocol,
|
||||
_DeltaSnapshot,
|
||||
)
|
||||
@@ -322,11 +324,29 @@ EXT_PYDANTIC_V1 = 4
|
||||
EXT_PYDANTIC_V2 = 5
|
||||
EXT_NUMPY_ARRAY = 6
|
||||
EXT_DELTA_SNAPSHOT = 7
|
||||
EXT_LOG_APPEND = 8
|
||||
EXT_LOG_STEP = 9
|
||||
|
||||
|
||||
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
|
||||
if isinstance(obj, _DeltaSnapshot):
|
||||
return ormsgpack.Ext(EXT_DELTA_SNAPSHOT, _msgpack_enc(obj.value))
|
||||
elif isinstance(obj, LogAppend):
|
||||
return ormsgpack.Ext(
|
||||
EXT_LOG_APPEND, _msgpack_enc([obj.start, list(obj.entries), obj.exec])
|
||||
)
|
||||
elif isinstance(obj, LogStep):
|
||||
return ormsgpack.Ext(
|
||||
EXT_LOG_STEP,
|
||||
_msgpack_enc(
|
||||
[
|
||||
obj.start,
|
||||
obj.count,
|
||||
obj.exec,
|
||||
None if obj.entries is None else list(obj.entries),
|
||||
]
|
||||
),
|
||||
)
|
||||
elif hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
|
||||
return ormsgpack.Ext(
|
||||
EXT_PYDANTIC_V2,
|
||||
@@ -659,6 +679,14 @@ def _create_msgpack_ext_hook(
|
||||
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
|
||||
)
|
||||
)
|
||||
elif code == EXT_LOG_APPEND:
|
||||
start, entries, exec_ = ormsgpack.unpackb(data)
|
||||
return LogAppend(start, tuple(entries), exec_)
|
||||
elif code == EXT_LOG_STEP:
|
||||
start, count, exec_, entries = ormsgpack.unpackb(data)
|
||||
return LogStep(
|
||||
start, count, exec_, None if entries is None else tuple(entries)
|
||||
)
|
||||
elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
tup = None
|
||||
try:
|
||||
|
||||
@@ -31,6 +31,52 @@ class _DeltaSnapshot(NamedTuple):
|
||||
value: Any
|
||||
|
||||
|
||||
class LogAppend(NamedTuple):
|
||||
"""One task write to a log channel, as handed to `put_writes`.
|
||||
|
||||
!!! warning "Prototype"
|
||||
|
||||
Part of the transcript log prototype. Shape and semantics may change.
|
||||
|
||||
`start` is the log's length at the checkpoint the step started from (the
|
||||
position the step's entries begin at). `entries` are the appended entries
|
||||
as JSON text, exactly as written. `exec` identifies the execution that
|
||||
wrote them, so a log-aware saver can keep two executions from one
|
||||
checkpoint apart.
|
||||
"""
|
||||
|
||||
start: int
|
||||
entries: tuple[str, ...]
|
||||
exec: str = ""
|
||||
|
||||
|
||||
class LogStep(NamedTuple):
|
||||
"""A log channel's value in a checkpoint: the step that produced its version.
|
||||
|
||||
!!! warning "Prototype"
|
||||
|
||||
Part of the transcript log prototype. Shape and semantics may change.
|
||||
|
||||
The step appended `count` entries starting at position `start`, so the
|
||||
log as of the checkpoint has `start + count` entries. `entries` carries
|
||||
the step's entries inline when they did not reach the saver through
|
||||
`put_writes` (graph input, `update_state`, `durability="exit"`); it is
|
||||
`None` when they did.
|
||||
"""
|
||||
|
||||
start: int
|
||||
count: int
|
||||
exec: str = ""
|
||||
entries: tuple[str, ...] | None = None
|
||||
|
||||
@property
|
||||
def end(self) -> int:
|
||||
return self.start + self.count
|
||||
|
||||
def stripped(self) -> "LogStep":
|
||||
return LogStep(self.start, self.count, self.exec, None)
|
||||
|
||||
|
||||
Value = TypeVar("Value", covariant=True)
|
||||
Update = TypeVar("Update", contravariant=True)
|
||||
C = TypeVar("C")
|
||||
|
||||
Generated
+207
-92
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
@@ -178,6 +178,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
@@ -212,6 +221,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/ff/b53af1b49bb2cb5fa97c92215614b173ca5c1a99ded80e2d52f15a94fa6d/httpcore2-2.14.0.tar.gz", hash = "sha256:4f57a1a8249fe6af589d935cfd456f1d13bbdcb0f0fc72af9142368fc94811d7", size = 69096, upload-time = "2026-10-18T16:29:21.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15'",
|
||||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
@@ -228,12 +271,62 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
name = "httpx2"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpcore2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15'",
|
||||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpcore2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2-jsfetch"
|
||||
version = "1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -268,7 +361,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.6.5"
|
||||
version = "1.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -282,21 +375,21 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/ed/ceecd3bfcfc77a94cf30a9f1506e907fdea447458825e01914a72d2d1a6f/langchain_core-1.6.5.tar.gz", hash = "sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b", size = 1007041, upload-time = "2026-09-24T18:11:06.487Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/ec/55c77c3d53c65fbf58d41bd78567ed34b5c0e9dcacd00e4568a9067314ab/langchain_core-1.7.1.tar.gz", hash = "sha256:5dc2a357e1aa40e84c7f29755182121b3acf04410e55fa3723915f645033fede", size = 1026919, upload-time = "2026-10-28T14:59:47.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/68/447ac7e734990b7ed9dcfc3b6dee9a4cd4d4169e296429fa71a9a47c1c7b/langchain_core-1.6.5-py3-none-any.whl", hash = "sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556", size = 572136, upload-time = "2026-09-24T18:11:04.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/87/50fa7f000065502ce76ec6b701a13adec62c32171785d5bebb0345219a0f/langchain_core-1.7.1-py3-none-any.whl", hash = "sha256:2d668788945e824832e19bca05788a25dbb864197d63ec48b583057cf8d975fa", size = 575541, upload-time = "2026-10-28T14:59:45.887Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.18"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/8b/d908d082c3cf5368ddd1816ed114de79c873f979ba7f98b95fab0e841643/langchain_protocol-0.0.20.tar.gz", hash = "sha256:0f5bd002474265648e91161771a9e0438fe9480bd55a416e51af0f60070637ec", size = 6280, upload-time = "2026-10-20T11:13:50.84Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/4b/f1c99a0b27c5ff8b4b131c0d6e62012c17cc424089efe213767db927afd2/langchain_protocol-0.0.20-py3-none-any.whl", hash = "sha256:3c935e5e46b0f6f9cb52f45ba7cfa53d1b9fa80c82c4a277d47f03e07f7385b1", size = 7347, upload-time = "2026-10-20T11:13:49.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -384,23 +477,28 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.18"
|
||||
version = "0.14.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "httpx2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
{ name = "websockets" },
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/45/576803a72da96d9c0866b3815f08f447ed16c14acb52174129daca6847d6/langsmith-0.14.16.tar.gz", hash = "sha256:4e5816cbe1541f4d04d71f652eb8f4137889861bebcf7137bcba3553ae9e335e", size = 5096529, upload-time = "2026-10-29T17:53:27.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3a/9a072c02a22089ddf20f0f33ea95bee5a5951b31839f118b04f27b94be6e/langsmith-0.14.16-py3-none-any.whl", hash = "sha256:b9ea1a57622b0788b181d78605f974fb206c5f6f75793076ffec23cef4d904c8", size = 933680, upload-time = "2026-10-29T17:53:25.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -806,37 +904,36 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c7/f9374c8939560ab22db1dd9dbe215b62d20f32634aeb7f259dc03c807b44/pycryptodome-4.2.0.tar.gz", hash = "sha256:21d7fdba239d6ef863d3d08388801c683453a3bb4897c2b27f27847aad89bdf7", size = 1750332, upload-time = "2026-10-24T10:24:42.016Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/05/61028b65913438a091ed3a2ec55bb50843e674b6ae14bccf196bbed652a2/pycryptodome-4.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:4b58c4fd5615a788e0df6793d578e565b19d75a348c0ba02d469b0c38bbf0332", size = 1684620, upload-time = "2026-10-24T10:23:50.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/73/0930fd56dd5eb856ecc33ec48845def52a1d25a9cd6b810a4c3ef3633cf4/pycryptodome-4.2.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42ab554efcc14928f1db42deac9c84989fd49e289a16aacbbee709fe2eb838cc", size = 1520423, upload-time = "2026-10-24T10:23:52.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ba/4b931de42881053a6d818b9ee70f6dde3dfe1cfcf78527d45db3f99d8b47/pycryptodome-4.2.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b4ed1a34d2d667ec165beb15313a759bcd8e704e2743f9942491c5a88a2d6ca5", size = 2296340, upload-time = "2026-10-24T10:23:54.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/63/68fc2a01d621c2ff389ea261e731e8f1abe6aba89992681bbcdb19fcca43/pycryptodome-4.2.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1482a7d9ceed7beadbb0853f83d4d44406a9f9fa8ee596d637618f095869e14", size = 2987589, upload-time = "2026-10-24T10:23:56.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/63/69e211459f4625c89bb47a7ba4527a6cc3276c9bbd79ae871a2d0b05c021/pycryptodome-4.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:dceadb6f478b0a687d129f1b58220e0587fb17a31b2502ae7a1a4234bc304ee1", size = 2322482, upload-time = "2026-10-24T10:23:58.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/21a4ed3ae5af153c57861d7f987c81fca5bf02d49b76f34c545f8eb6b95f/pycryptodome-4.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8217736d5ac9b7fef49d1d2b292d5af4e8300298ef4926742aaec49b2bf6505b", size = 3548352, upload-time = "2026-10-24T10:24:01.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a4/9a7e4e8896cfd8131978d31e39c930fb114f6a63751f39d2069b965dad45/pycryptodome-4.2.0-cp315-cp315t-win32.whl", hash = "sha256:b717e36cf14cffe463ff4e7d423a0c9fd6f83b40d207388e0f40dd0a3d4018bc", size = 1306386, upload-time = "2026-10-24T10:24:03.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/6c/e1a78019b7db83e2cfc1c98cea4a7a0afbb6b233514b9dfee2bad7443bd9/pycryptodome-4.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:585f0a792cdc7fb381ca0d1fac4e20f03fd99bb78cf2733eaab59f62c77ccf88", size = 1688319, upload-time = "2026-10-24T10:24:04.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/96/7cb051ba325141b33bc7e6ef6152499cf74b74606c5bb4c8c72411e8afdc/pycryptodome-4.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:831ae8ce070e990d4fcb913c49c7fb0181ea7ab2691fc58dcb9d41fdb5384d85", size = 1330914, upload-time = "2026-10-24T10:24:06.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/4c/954a9a1451f7bcc89d66db39bf6499ef5701e8ff35b6c898c659a7ec8546/pycryptodome-4.2.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e430168f767ecec60c1f850df4abbd0b9f94786b9cc8041a00e976f6d5190c6", size = 1680663, upload-time = "2026-10-24T10:24:08.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/84/23f8013dce07cbc8c8b9ed878972294230a1edc49a6b64d271c77c878c29/pycryptodome-4.2.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aea22120791b4534a27e39fc01d91265a249ba72dbd682a8a5c1adee475d5483", size = 1513323, upload-time = "2026-10-24T10:24:11.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/82/693940a8e4a20c3db91050480429daf39c818be3c7ec59f992ded4567ce6/pycryptodome-4.2.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6f30a457bd83a3074747d11bcb95375397a2e9b9a6b9179ab001e0cdd8ed0c5", size = 2292885, upload-time = "2026-10-24T10:24:13.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/88/33df6b1176b63aed427745696d88ed38f7877104cf0f6004ee9ad600f897/pycryptodome-4.2.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:76777686de3a354c41710727988c2284448d5db9bd82e5ddb9a9b6710d17d9df", size = 2976488, upload-time = "2026-10-24T10:24:16.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c6/c1c137d6e3284409ca5b2cfd7db3621167c316f9c314a96274af69a66fbb/pycryptodome-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c5bc41a418451616cd0ec3d765ba8539416d7429d7cd633ffb603a5a22355e70", size = 2313324, upload-time = "2026-10-24T10:24:18.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/b7752cffc8200040835ad24dc17c9de6376ac27612c8bf943766cb1b3c36/pycryptodome-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2c5b1e9058687daca3aaaff23eeb1c1853c8251986f0de7a950235e8bc0cc8b7", size = 3537773, upload-time = "2026-10-24T10:24:20.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/1b/34bec1416507cda23b5dfa33c0bca936fd4ac8ed4d3269803477b897649d/pycryptodome-4.2.0-cp39-abi3-win32.whl", hash = "sha256:bb7659f87d7aa69105e1be12764f9cf1908287eb424b242186f5ac3c192ed612", size = 1292974, upload-time = "2026-10-24T10:24:22.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5b/a4a31e6e1aabd5a26da77c7960e7fcce4fca9132e51868f1bba10bf1e932/pycryptodome-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:3282c9fd744a16594e51e1a9fbfe4d658b6b0aaa1f050926e6d591ac7494f78b", size = 1658684, upload-time = "2026-10-24T10:24:24.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/81/bddb0637fa85a03d00eea2f3514c5aa8d7b9c4921ef5e0c125b4ce662a8c/pycryptodome-4.2.0-cp39-abi3-win_arm64.whl", hash = "sha256:34ec11258967efec61d92a83a63a050ab7a7c31dc3806412f6334b8aef47e2d9", size = 1305591, upload-time = "2026-10-24T10:24:25.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a1/5460bbb750121f71abacaaf25f0cb1df67be7fe13c70cf6d7c2b8ad54fc0/pycryptodome-4.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7ea7dbf6b8ce0f0d2c0e17c1226c74d07c01499db7704797bdbb6cb9ec511ccb", size = 1472158, upload-time = "2026-10-24T10:24:27.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/65/27ac8fb15f0ffb70b72a0413291637de46d29b075804221703273c3ca6d1/pycryptodome-4.2.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0106af8684e1d73c93bc39a2f1cb4ae2f2636d1f80e6291caddcc37b4efc5dc1", size = 1247107, upload-time = "2026-10-24T10:24:29.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7d/6b3ed6cd8626007f6a6b13616b08150bc0bb936a0b84c310e59558ed5136/pycryptodome-4.2.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4deb46e0d0a540b1f047cb6f430249692da15c21edd75f5e6278314089fb859", size = 1426092, upload-time = "2026-10-24T10:24:31.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/45/5adf4dbb1eab1da6291d25f777d1d34f745c7f2993bf2d4d3b0d9abb87c6/pycryptodome-4.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e094873fe87ae4d546ef393cec7ea1e1cf8e0684f649612b19352afa67eb8bdb", size = 1662257, upload-time = "2026-10-24T10:24:33.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/88/fef93a9ecbda783a312b15a8823fa1a9a4ec3474685e756584af595457aa/pycryptodome-4.2.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:30f418cd1f4e776bde6c5df1fcd90ddfe74dfc8f679286a82f17ec1d4018cce0", size = 1467319, upload-time = "2026-10-24T10:24:35.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/65/cb53934c9f8d5c41dfcfaddb0d98103f4dd6670af1ffdbc2582bcdd99382/pycryptodome-4.2.0-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20cb4a489f7a5150a882901884b84ee0df77a47b9a6eb6e2dd8e98997b7b826f", size = 1247105, upload-time = "2026-10-24T10:24:36.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/38/5bba7fd24beb6bc7a82fdd155f2df39f51c8ad16143fc99d3a1ccc7c76b4/pycryptodome-4.2.0-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f104dc1a38aa1172a166ebe3716f2666f785bfa015b7ca13bb808c12c902e455", size = 1426092, upload-time = "2026-10-24T10:24:38.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/cf/e70a8cb1ab184b59b2d02dfd403965a0c6419571d8520463944bcb0ca8a0/pycryptodome-4.2.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:a7c6857077255f5f4e6c34e6325802ba1bb59960133c0a5e584d9371dbdf3dea", size = 1662214, upload-time = "2026-10-24T10:24:40.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -983,7 +1080,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -994,35 +1091,35 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.16.0"
|
||||
version = "3.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz", hash = "sha256:e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4", size = 43064, upload-time = "2026-10-17T16:21:42.435Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl", hash = "sha256:1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b", size = 11248, upload-time = "2026-10-17T16:21:41.265Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1164,27 +1261,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
version = "0.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1196,6 +1293,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "9.1.2"
|
||||
@@ -1260,28 +1366,37 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.84"
|
||||
name = "truststore"
|
||||
version = "0.10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.94"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/c7/1257c8c70e46274664db1eaf2b8aba99a1277470daaa39c65e7decd67c8f/ty-0.0.94.tar.gz", hash = "sha256:933d2feca6057742d04af00c090621c139e7be2c059aa38b040a70532fd5104b", size = 8106769, upload-time = "2026-10-28T18:06:09.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/bd/5145d91cf196cb20fc3b005515b21266a419ea7336d3d7891a1769439259/ty-0.0.94-py3-none-linux_armv6l.whl", hash = "sha256:c6463c045880ad17df911a53d367eb2554b17538ca54911bea6ccb20ec95ee11", size = 14528574, upload-time = "2026-10-28T18:05:19.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/0e/2b563132429b5fc6044095061fde4584548cb55abf0d6d9f2829c41ab226/ty-0.0.94-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec3a3892f4290306c674586149668097cf44f13e8b381ba388662f5a440b459b", size = 14067146, upload-time = "2026-10-28T18:05:23.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/3a458362ea33ca9756cd6b914f905b3dec007d329b21d83e859ecee65a63/ty-0.0.94-py3-none-macosx_11_0_arm64.whl", hash = "sha256:74ddc6eeecd3251cf8980c9abb960d7eeab649dac5cdb7504910d99c479f84ab", size = 13148266, upload-time = "2026-10-28T18:05:25.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/38/5380bdaad2745a4e191deacce96403af74d1992ed8782d5766c301c32b50/ty-0.0.94-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31bdc3aace142400d7e22b4d32676c063ec42f0437d77940012c3dc60587ecdd", size = 13973583, upload-time = "2026-10-28T18:05:28.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/bc/7e1dc4f2594010aff6d1e2e34d0ff9003736210983be5720a04d69c27e03/ty-0.0.94-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fecacfe9c41ed1109aa503ed172d84b1b0fc283ee49aaf078d298dbe1c62e75f", size = 14269235, upload-time = "2026-10-28T18:05:31.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/43041f1c53164d6be21ace55047feffeaa8a9709782b01b06c951412b900/ty-0.0.94-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a49762bbc97a732847da8350ad0227c99ce2675fa32e3f58aa9f9ff4a81e4c14", size = 15191720, upload-time = "2026-10-28T18:05:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/72/f425d48b13cbf12b92e4f837a5866e9788e9ef4d5a071e2b12f54ee94da4/ty-0.0.94-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76a7b3aea839dfafb4bc068af918e0292b06ac26ea2a43f7ab3dd65548c0ed50", size = 15596604, upload-time = "2026-10-28T18:05:37.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/ea/bd2da666ad4af596886833b095098fa9ff93685285f48bebc4d3865126e0/ty-0.0.94-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f032ce5a9c0c7033a5a8e748f952ece2e22afe2b148d211d05dca2cba8b28000", size = 15572711, upload-time = "2026-10-28T18:05:40.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/f8/4f07eac57e9b60f49cc222d5048d9a61ac7f0902964ec8ac8d4ea8bc521d/ty-0.0.94-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc5e780c2107c1b83a21a394076da328e05a293ce06298df40ecab1ddc31d371", size = 16309102, upload-time = "2026-10-28T18:05:43.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/39/c2ac7e14eb8ca59bd3c71b8c3055294524df84c476ba88545f59c11ceaf9/ty-0.0.94-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5bc969cdabf85aa66987c9b4818072e3af17ce5f2b7bc93550ab896c904c6c40", size = 15073645, upload-time = "2026-10-28T18:05:46.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/7d/dd4935abf50a97ccc1f46cf6c921a7acdab30f102c3c56aa4e8d6cbc3fa9/ty-0.0.94-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:27bbaff0d7bbcb3e92ed7cab07d6b26dc7ed7fa28ff69910324e5cc347c7b256", size = 13936242, upload-time = "2026-10-28T18:05:49.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f0/b65fa6ccde2d629e25031bf2cc2d21ac7b50ee6e0e3d29a5f471c4686c54/ty-0.0.94-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d0960eb71220300655d67db91073fd180f385186b1d186e76faa2fd3ae5bd10f", size = 14280351, upload-time = "2026-10-28T18:05:52.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d2/5c901ad5e4f5444852b002b1f4dd3a0b470039305c688facc0483bfccd1a/ty-0.0.94-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab04c14bca92ab92106ceaa19a962a80998389700cf7938576ba3936a304355d", size = 14611163, upload-time = "2026-10-28T18:05:55.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/99/37d8633e9b5d1dcf1b06f12466825ff0c4028664994e573c352546ee9cbd/ty-0.0.94-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a92ebefa092e6bd58cb5719626858df744575473e2de0cd1c44abd44b9b84b57", size = 14938978, upload-time = "2026-10-28T18:05:58.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/13/804b1f993bf84a819385f8b440fbc4b894499e2c20d865cd74d69327e7b8/ty-0.0.94-py3-none-win32.whl", hash = "sha256:3212dcf30b7567f2bb65a83cdfc4818a5385f82e7afddd83548b0d84e759057e", size = 13448009, upload-time = "2026-10-28T18:06:01.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/23/81850750cda0ea781ba9d6e08cea1a1039361cb3474c7dd03765bbc4b51f/ty-0.0.94-py3-none-win_amd64.whl", hash = "sha256:daf5bafe8e7d21b8a99a56fc8518f2342a2dbcaa370a5c25cfaedc684088d158", size = 14010576, upload-time = "2026-10-28T18:06:03.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/ac3a759556493230823abd31e149c95401f96b9cac36567bd403a8454ea3/ty-0.0.94-py3-none-win_arm64.whl", hash = "sha256:ce344353ccdc542cfcfd6abe1481cf23c005f483411ea79652b78ce0beae36f2", size = 13547398, upload-time = "2026-10-28T18:06:06.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -5,5 +5,5 @@ description = "Test for prerelease stuff"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langgraph==1.2.6"
|
||||
"langgraph==1.2.19"
|
||||
]
|
||||
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"langchain-openai==1.1.14",
|
||||
"langchain-anthropic==1.4.6",
|
||||
"langgraph==1.2.6"
|
||||
"langgraph==1.2.19"
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"test:all": "yarn test && yarn test:int && yarn lint:langgraph"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^1.2.13",
|
||||
"@langchain/langgraph": "^1.4.18"
|
||||
"@langchain/core": "^1.3.1",
|
||||
"@langchain/langgraph": "^1.5.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@langchain/langgraph-checkpoint": "1.0.4",
|
||||
@@ -34,17 +34,17 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"dotenv": "^18.0.4",
|
||||
"eslint": "^10.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.72.1",
|
||||
"@typescript-eslint/parser": "^8.72.1",
|
||||
"dotenv": "^18.0.9",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"jest": "^30.5.2",
|
||||
"prettier": "^3.9.9",
|
||||
"ts-jest": "^29.4.14",
|
||||
"prettier": "^3.9.10",
|
||||
"ts-jest": "^29.4.15",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
+126
-123
@@ -387,6 +387,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6"
|
||||
integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==
|
||||
|
||||
"@dual-bundle/import-meta-resolve@^4.2.1":
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz#cd0b25b3808cd9e684cd6cd549bbf8e1dcf05ee7"
|
||||
integrity sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==
|
||||
|
||||
"@emnapi/core@1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
|
||||
@@ -890,50 +895,50 @@
|
||||
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
|
||||
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
|
||||
|
||||
"@langchain/core@^1.2.13":
|
||||
version "1.2.13"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.2.13.tgz#4ea4eb0308a25af350f75de2fbf34aecb819177e"
|
||||
integrity sha512-ADGTxZ84n3civruUX1LlTOdua/PDGoni2U6TmKTX7hvRU2Jlepu8vLJI4Wnwj45KUKhUCrW94Il12Q2bxE3e8A==
|
||||
"@langchain/core@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.3.1.tgz#f6cdb6d200def5c2e69d25ff4fef92612d1b7601"
|
||||
integrity sha512-jt0miXWxNi+rbZLeSeZkc7BBYKnWopksoJSpB+P3zaYgzMMNwkAPWqnd1zSClfYGwLZPXQbbTtaqv8ZFXmE3FA==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
"@standard-schema/spec" "^1.1.0"
|
||||
js-tiktoken "^1.0.12"
|
||||
langsmith ">=0.5.0 <1.0.0"
|
||||
langsmith ">=0.11.0 <1.0.0"
|
||||
mustache "^4.2.0"
|
||||
p-queue "^6.6.2"
|
||||
zod "^3.25.76 || ^4"
|
||||
|
||||
"@langchain/langgraph-checkpoint@1.0.4", "@langchain/langgraph-checkpoint@^1.1.5":
|
||||
"@langchain/langgraph-checkpoint@1.0.4", "@langchain/langgraph-checkpoint@^1.2.1":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.4.tgz#893aa7255b2fe810fdeb50281f6750a3b0a6fef7"
|
||||
integrity sha512-1y5MgZ0gXXrtmoy56e3kaBChI3GwFPIKl27xkrHwN+VE/3iUsyr9gO3Jtp7kdKAe6diZGbcas5bdC/r0yUwTZA==
|
||||
dependencies:
|
||||
uuid "^14.0.0"
|
||||
|
||||
"@langchain/langgraph-sdk@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.0.tgz#b063a7eba0a66f51cbc841a824626a47eca1eec9"
|
||||
integrity sha512-F3AOZjKZRUGmE3FzY+RaVZI6WzXOkwnuF7jqgto6rDPSnO9OdVdYGvwGDi3jjRGf5xLoDtX2dpsR9z5KptU+5A==
|
||||
"@langchain/langgraph-sdk@~1.12.8":
|
||||
version "1.12.9"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.9.tgz#b8e66e00af207f18e56eb6cd916c1ee8e7608745"
|
||||
integrity sha512-A1LkLPVu00dj+9aErXPBLhmFURHtnnRO9KRFST+xAwurqQTa+LxC9ixxF7p+ZMo5P70qMFoaP8Ud2wuZmYD4xw==
|
||||
dependencies:
|
||||
"@langchain/protocol" "^0.0.19"
|
||||
"@langchain/protocol" "^0.0.20"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
|
||||
"@langchain/langgraph@^1.4.18":
|
||||
version "1.4.18"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.4.18.tgz#c8cc4ea734834262fdb00ae6a45a0849620e4fd2"
|
||||
integrity sha512-yrMMJ9hk2NVMD2xU2WoVrgFawAU6s/RzEl/dX9BhlyxZHazo1wHY35z4K2BIBzTUh4z3giCzrUoqRAqW2CWpWg==
|
||||
"@langchain/langgraph@^1.5.3":
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.5.3.tgz#9a921c9e099c2e3009f34987e0acb2645636bb74"
|
||||
integrity sha512-cuqswNgfoF/J3/r9K5Qm7EnQc2f0mz7uWlAAs751KWhoK8sKfmuLABghF4b1LYq1NpK72XG1x8lWPyg45M8BOA==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.1.5"
|
||||
"@langchain/langgraph-sdk" "~1.12.0"
|
||||
"@langchain/protocol" "^0.0.19"
|
||||
"@langchain/langgraph-checkpoint" "^1.2.1"
|
||||
"@langchain/langgraph-sdk" "~1.12.8"
|
||||
"@langchain/protocol" "^0.0.20"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
|
||||
"@langchain/protocol@^0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.19.tgz#7fe43fed115dc34b3b4c246e8d5283fbcbfab7b0"
|
||||
integrity sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==
|
||||
"@langchain/protocol@^0.0.20":
|
||||
version "0.0.20"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.20.tgz#441a57a6176f634306d9214da65d35033355d5e9"
|
||||
integrity sha512-sxlkDG2/HGyjDFLTJ8B/o3oglRjOv8iLCFgrV0550/Vym3V6RZ0SUjAQFe8jrLQw8ZXjERy3swJp5OODBSrUYA==
|
||||
|
||||
"@napi-rs/wasm-runtime@^1.1.4":
|
||||
version "1.2.3"
|
||||
@@ -1180,110 +1185,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz#d404bac4cb04e4dcd20086e46e13c952bd0771e0"
|
||||
integrity sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==
|
||||
"@typescript-eslint/eslint-plugin@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz#95e29de63ce7ed793eb1c6efd9f8b802ce808262"
|
||||
integrity sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/type-utils" "8.70.1"
|
||||
"@typescript-eslint/utils" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/scope-manager" "8.72.1"
|
||||
"@typescript-eslint/type-utils" "8.72.1"
|
||||
"@typescript-eslint/utils" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.70.1.tgz#6c99b7b954efc05dae179001c8b477d1a8948dcf"
|
||||
integrity sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==
|
||||
"@typescript-eslint/parser@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.72.1.tgz#7370b68dadc3819b9aac68ea340bd8b43ee62379"
|
||||
integrity sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/scope-manager" "8.72.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/typescript-estree" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.70.1.tgz#d5635594cc8a573f0e15a772b1704e93a00134c1"
|
||||
integrity sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==
|
||||
"@typescript-eslint/project-service@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.72.1.tgz#1d09902a9c23f6cfab779e5acc78a45abf540add"
|
||||
integrity sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.70.1"
|
||||
"@typescript-eslint/types" "^8.70.1"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.72.1"
|
||||
"@typescript-eslint/types" "^8.72.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz#0feeae4da2d279b960cdbfe5e0a02fbf1195e93f"
|
||||
integrity sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==
|
||||
"@typescript-eslint/scope-manager@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz#a0fb4dfe5aa2b0149b7a2d1fd00d947992b13067"
|
||||
integrity sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz#e9353a6e5f3498a29b40946ca3e11f6166fef09e"
|
||||
integrity sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==
|
||||
"@typescript-eslint/tsconfig-utils@8.72.1", "@typescript-eslint/tsconfig-utils@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz#07394c85095ee38455e76bc221ba23650af73e44"
|
||||
integrity sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.70.1":
|
||||
version "8.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz#83a69c9a514af8f5ff099cc6c0d609c273e8f039"
|
||||
integrity sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz#6ea36f2c7dc0776c1c8e1c380b4896f00bc4c27f"
|
||||
integrity sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==
|
||||
"@typescript-eslint/type-utils@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz#f1f592f63bd9726de21191aae3ccf64a813914b8"
|
||||
integrity sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/utils" "8.70.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/typescript-estree" "8.72.1"
|
||||
"@typescript-eslint/utils" "8.72.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.1.tgz#2022b9d07d057174eba25b9bbd289641d262a196"
|
||||
integrity sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==
|
||||
"@typescript-eslint/types@8.72.1", "@typescript-eslint/types@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.72.1.tgz#067d8f738c294611e33a1425ae43ac9b9c2f24e3"
|
||||
integrity sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==
|
||||
|
||||
"@typescript-eslint/types@^8.70.1":
|
||||
version "8.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.71.0.tgz#a230a121e71665060afe27d1f1ad110371f9451e"
|
||||
integrity sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz#ced48fee6f7043835d2edfed9786ae74813a3c99"
|
||||
integrity sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==
|
||||
"@typescript-eslint/typescript-estree@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz#e93b9cc3a56bf12f5fed857bb8f4963cce7924ee"
|
||||
integrity sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.70.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/project-service" "8.72.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.72.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.70.1.tgz#6a97887cee702df513692f5cdc7f79baeef48e02"
|
||||
integrity sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==
|
||||
"@typescript-eslint/utils@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.72.1.tgz#f210c1bf4a25aa1306df80c702ed30bbbc9b4d3d"
|
||||
integrity sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/scope-manager" "8.72.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/typescript-estree" "8.72.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz#b4cde0ed16982bd963a6310fcae554f59fd14124"
|
||||
integrity sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==
|
||||
"@typescript-eslint/visitor-keys@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz#c7aec9883b44ef795f1b8200df81d9d44d355de7"
|
||||
integrity sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@typescript/typescript-aix-ppc64@7.0.2":
|
||||
@@ -2109,10 +2104,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
dotenv@^18.0.4:
|
||||
version "18.0.4"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-18.0.4.tgz#419bbe784f30915bfeb10e2d407e13ca5528504f"
|
||||
integrity sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==
|
||||
dotenv@^18.0.9:
|
||||
version "18.0.9"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-18.0.9.tgz#5c565760ce7e6fb69f9477ebcd1a3b64c766f9c8"
|
||||
integrity sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==
|
||||
|
||||
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
@@ -2430,13 +2425,13 @@ eslint-plugin-no-instanceof@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-no-instanceof/-/eslint-plugin-no-instanceof-1.0.1.tgz#5d9fc86d160df6991b654b294a62390207f1bb97"
|
||||
integrity sha512-zlqQ7EsfzbRO68uI+p8FIE7zYB4njs+nNbkNjSb5QmLi2et67zQLqSeaao5U9SpnlZTTJC87nS2oyHo2ACtajw==
|
||||
|
||||
eslint-plugin-prettier@^5.5.6:
|
||||
version "5.5.6"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
|
||||
integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
|
||||
eslint-plugin-prettier@^5.5.7:
|
||||
version "5.5.7"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz#ebef3acc2d664333b1356b717780198abc635398"
|
||||
integrity sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.13"
|
||||
synckit "^0.12.3"
|
||||
|
||||
eslint-scope@^9.1.2:
|
||||
version "9.1.2"
|
||||
@@ -2463,10 +2458,10 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||
|
||||
eslint@^10.11.0:
|
||||
version "10.11.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.11.0.tgz#304d1591b7c6a327e3f64b4f550f99fda160ae20"
|
||||
integrity sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==
|
||||
eslint@^10.13.0:
|
||||
version "10.13.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.13.0.tgz#a8a4742648c0fa9131f429b509de5407682d4864"
|
||||
integrity sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
@@ -2866,7 +2861,7 @@ graceful-fs@^4.2.11:
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||
|
||||
handlebars@^4.7.9:
|
||||
handlebars@^4.7.10:
|
||||
version "4.7.10"
|
||||
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.10.tgz#12419ea1b41391b3ffcf003f2164dcbce090c417"
|
||||
integrity sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==
|
||||
@@ -3854,10 +3849,10 @@ keyv@^5.6.0:
|
||||
dependencies:
|
||||
"@keyv/serialize" "^1.1.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.6.3.tgz#a3d8ad58d66a47d3697e3c69b2be3f6df5233190"
|
||||
integrity sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA==
|
||||
"langsmith@>=0.11.0 <1.0.0":
|
||||
version "0.11.6"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.11.6.tgz#cdc0f89a14632f8b172deea5daa1ee625f7442c3"
|
||||
integrity sha512-5hfJM5DEZpXFxUS3U9nY2CMyBRmFnhkJj7znUODJ8vsr/Jx38tGJl4GIhV484xAY7yQFWrFOTDr33PB95kHUKg==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
|
||||
@@ -4312,10 +4307,10 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.9.9:
|
||||
version "3.9.9"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.9.tgz#09b826918c91cd4cbc80e0cbd1d2a922ff04f233"
|
||||
integrity sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==
|
||||
prettier@^3.9.10:
|
||||
version "3.9.10"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.10.tgz#ac378e432f1a7381518f2e080816bbaefa38d585"
|
||||
integrity sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==
|
||||
|
||||
pretty-format@30.2.0, pretty-format@^30.0.0:
|
||||
version "30.2.0"
|
||||
@@ -4759,13 +4754,21 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
synckit@^0.11.13, synckit@^0.11.8:
|
||||
synckit@^0.11.8:
|
||||
version "0.11.13"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
|
||||
integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.3.6"
|
||||
|
||||
synckit@^0.12.3:
|
||||
version "0.12.3"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.12.3.tgz#6e30a3441d095ed04c311077350ce205465512d6"
|
||||
integrity sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==
|
||||
dependencies:
|
||||
"@dual-bundle/import-meta-resolve" "^4.2.1"
|
||||
"@pkgr/core" "^0.3.6"
|
||||
|
||||
test-exclude@^7.0.1:
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.2.tgz#482392077630bc57d5630c13abe908bb910dfc65"
|
||||
@@ -4800,14 +4803,14 @@ ts-api-utils@^2.5.0:
|
||||
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1"
|
||||
integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==
|
||||
|
||||
ts-jest@^29.4.14:
|
||||
version "29.4.14"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.14.tgz#cb36c043df0fada861c8b604a5d514f953126180"
|
||||
integrity sha512-+943G6wCX5E3VpzTQhBGBFntCmdT+QAkTvUlp03edTONFBvqskuXPhpyX16xdUotO4P5HQKyppldSWjocoltLQ==
|
||||
ts-jest@^29.4.15:
|
||||
version "29.4.15"
|
||||
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.15.tgz#238f2d495066a33ca973a54a36a5ef228262a9ad"
|
||||
integrity sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==
|
||||
dependencies:
|
||||
bs-logger "^0.2.6"
|
||||
fast-json-stable-stringify "^2.1.0"
|
||||
handlebars "^4.7.9"
|
||||
handlebars "^4.7.10"
|
||||
json5 "^2.2.3"
|
||||
lodash.memoize "^4.1.2"
|
||||
make-error "^1.3.6"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@js-monorepo-example/shared": "*",
|
||||
"@langchain/core": "^1.2.13",
|
||||
"@langchain/langgraph": "^1.4.18"
|
||||
"@langchain/core": "^1.3.1",
|
||||
"@langchain/langgraph": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2"
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.11.5",
|
||||
"turbo": "^2.11.10",
|
||||
"typescript": "^7.0.2",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"prettier": "^3.9.9"
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.72.1",
|
||||
"@typescript-eslint/parser": "^8.72.1",
|
||||
"prettier": "^3.9.10"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6"
|
||||
integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==
|
||||
|
||||
"@dual-bundle/import-meta-resolve@^4.2.1":
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz#cd0b25b3808cd9e684cd6cd549bbf8e1dcf05ee7"
|
||||
integrity sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
|
||||
version "4.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
|
||||
@@ -134,48 +139,48 @@
|
||||
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
|
||||
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
|
||||
|
||||
"@langchain/core@^1.2.13":
|
||||
version "1.2.13"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.2.13.tgz#4ea4eb0308a25af350f75de2fbf34aecb819177e"
|
||||
integrity sha512-ADGTxZ84n3civruUX1LlTOdua/PDGoni2U6TmKTX7hvRU2Jlepu8vLJI4Wnwj45KUKhUCrW94Il12Q2bxE3e8A==
|
||||
"@langchain/core@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.3.1.tgz#f6cdb6d200def5c2e69d25ff4fef92612d1b7601"
|
||||
integrity sha512-jt0miXWxNi+rbZLeSeZkc7BBYKnWopksoJSpB+P3zaYgzMMNwkAPWqnd1zSClfYGwLZPXQbbTtaqv8ZFXmE3FA==
|
||||
dependencies:
|
||||
"@cfworker/json-schema" "^4.0.2"
|
||||
"@standard-schema/spec" "^1.1.0"
|
||||
js-tiktoken "^1.0.12"
|
||||
langsmith ">=0.5.0 <1.0.0"
|
||||
langsmith ">=0.11.0 <1.0.0"
|
||||
mustache "^4.2.0"
|
||||
p-queue "^6.6.2"
|
||||
zod "^3.25.76 || ^4"
|
||||
|
||||
"@langchain/langgraph-checkpoint@^1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.5.tgz#c793177f9afcce31a1e9922f317f88fec1254282"
|
||||
integrity sha512-BwDwl5VeTOh6CVuiIPgsUgfK51vTJDMSbFcSCUfjJWsl8/DPdK/mbv+ejxJstkSk/BlSPMP4JfXWcN6jD2ea2Q==
|
||||
"@langchain/langgraph-checkpoint@^1.2.1":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.2.2.tgz#cbfdf0d461385769665e26f70e41f1291ed1d37e"
|
||||
integrity sha512-KL3nzi7I61ll5nANCQe5RoJ5SqTFrE+si9IK6k7IIj9OrPkIKT/Dz0w8CyJF+K7OEuJfA33HvPrZqfzzW1DXDA==
|
||||
|
||||
"@langchain/langgraph-sdk@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.0.tgz#b063a7eba0a66f51cbc841a824626a47eca1eec9"
|
||||
integrity sha512-F3AOZjKZRUGmE3FzY+RaVZI6WzXOkwnuF7jqgto6rDPSnO9OdVdYGvwGDi3jjRGf5xLoDtX2dpsR9z5KptU+5A==
|
||||
"@langchain/langgraph-sdk@~1.12.8":
|
||||
version "1.12.9"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.9.tgz#b8e66e00af207f18e56eb6cd916c1ee8e7608745"
|
||||
integrity sha512-A1LkLPVu00dj+9aErXPBLhmFURHtnnRO9KRFST+xAwurqQTa+LxC9ixxF7p+ZMo5P70qMFoaP8Ud2wuZmYD4xw==
|
||||
dependencies:
|
||||
"@langchain/protocol" "^0.0.19"
|
||||
"@langchain/protocol" "^0.0.20"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
p-queue "^9.0.1"
|
||||
p-retry "^7.1.1"
|
||||
|
||||
"@langchain/langgraph@^1.4.18":
|
||||
version "1.4.18"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.4.18.tgz#c8cc4ea734834262fdb00ae6a45a0849620e4fd2"
|
||||
integrity sha512-yrMMJ9hk2NVMD2xU2WoVrgFawAU6s/RzEl/dX9BhlyxZHazo1wHY35z4K2BIBzTUh4z3giCzrUoqRAqW2CWpWg==
|
||||
"@langchain/langgraph@^1.5.3":
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.5.3.tgz#9a921c9e099c2e3009f34987e0acb2645636bb74"
|
||||
integrity sha512-cuqswNgfoF/J3/r9K5Qm7EnQc2f0mz7uWlAAs751KWhoK8sKfmuLABghF4b1LYq1NpK72XG1x8lWPyg45M8BOA==
|
||||
dependencies:
|
||||
"@langchain/langgraph-checkpoint" "^1.1.5"
|
||||
"@langchain/langgraph-sdk" "~1.12.0"
|
||||
"@langchain/protocol" "^0.0.19"
|
||||
"@langchain/langgraph-checkpoint" "^1.2.1"
|
||||
"@langchain/langgraph-sdk" "~1.12.8"
|
||||
"@langchain/protocol" "^0.0.20"
|
||||
"@standard-schema/spec" "1.1.0"
|
||||
|
||||
"@langchain/protocol@^0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.19.tgz#7fe43fed115dc34b3b4c246e8d5283fbcbfab7b0"
|
||||
integrity sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==
|
||||
"@langchain/protocol@^0.0.20":
|
||||
version "0.0.20"
|
||||
resolved "https://registry.yarnpkg.com/@langchain/protocol/-/protocol-0.0.20.tgz#441a57a6176f634306d9214da65d35033355d5e9"
|
||||
integrity sha512-sxlkDG2/HGyjDFLTJ8B/o3oglRjOv8iLCFgrV0550/Vym3V6RZ0SUjAQFe8jrLQw8ZXjERy3swJp5OODBSrUYA==
|
||||
|
||||
"@pkgr/core@^0.3.6":
|
||||
version "0.3.6"
|
||||
@@ -197,35 +202,35 @@
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.13.tgz#269fce3ad04ca70b93269ff44cca81b950f542da"
|
||||
integrity sha512-sySRuBfMKyKO/j2ZAhR8kSembhjuPEV4Ra3AHtmWLq51+iGaudr45crPSzNC5b7/Ctrh9dfUpBuTlYrH6rM58Q==
|
||||
|
||||
"@turbo/darwin-64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.11.5.tgz#6ddc580c2b7d05e578ca1c06b3f61473f4b7b00e"
|
||||
integrity sha512-KdY4y7BBjIisl+jAkb3FFaRjClGqHyaeHCcVcCT8LBzFFwUu6thgfxyRZAx0uL4KoxLmH+iOan8UhVf/aDDcaA==
|
||||
"@turbo/darwin-64@2.11.10":
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-64/-/darwin-64-2.11.10.tgz#824aa74de92806ef2a21932b4148feaea98e8887"
|
||||
integrity sha512-By4idRap5+nLnMuRzLB4qkOPRlvNdyY1ne4VJYDyUoUB11H94k7uif9mbe3JT8JSNXs1D68uMcBj6hJ/jMQRvA==
|
||||
|
||||
"@turbo/darwin-arm64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.11.5.tgz#a756c67bb828cd00d03e909769dd39453c1f1e69"
|
||||
integrity sha512-ToZDGi2u410+TfRtC/O1/LpujGgvNJoRBZnwVXob7oO0+3OSlwIO3zofXKUvwQvlba7a8ZXPyCZwufjAXd7f0w==
|
||||
"@turbo/darwin-arm64@2.11.10":
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/darwin-arm64/-/darwin-arm64-2.11.10.tgz#e9de7c9b9eb2a428cd4fb63fb9826696107339a4"
|
||||
integrity sha512-pyEYlVcuexOPa92p0Wlwl9YT1Is5D60lxN3ItWm3g30YLvFaUq86TF4BBWg5ZAc0uMTnrOzCvuCsOBTdzUWnRQ==
|
||||
|
||||
"@turbo/linux-64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.11.5.tgz#b95c05218aebb06c06e5f991d48c55b78dc5e005"
|
||||
integrity sha512-Ub2d/nAbdWLaSytcEaPp1wm/YuIvHEIqNrCRMI/UgOGyFdOhgxm8VyWPAJilQWRYxYVrqEUoy2K5S5K8SG50rA==
|
||||
"@turbo/linux-64@2.11.10":
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-64/-/linux-64-2.11.10.tgz#e9f48a6383e7c6926a488f329343792de3ee537c"
|
||||
integrity sha512-1tYeR/IDZ5HEdsb0jNq2L2xam5nMQ+g80tlV2BZmv4OFw+pMcUM0mMmUcfc5wXPPNHVNPuDMjh9Y8G8HgXPLZA==
|
||||
|
||||
"@turbo/linux-arm64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.11.5.tgz#c7a1d1a44c8c8dcff4b72fbafe9a5059c8fb668a"
|
||||
integrity sha512-JQjmGxp724Wuft9FAxPMtmBur1CEdUyxJ+FVoIvKkgj63rXHl9PYCWrsU2tMjVi609XTQ3M8vZD9Yc9qVAVYrw==
|
||||
"@turbo/linux-arm64@2.11.10":
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/linux-arm64/-/linux-arm64-2.11.10.tgz#d76831f850d30ef386b221b8ee72270a5ee7e2ba"
|
||||
integrity sha512-HuekQxvVGhUd46g1Im/DKdOtglHr4/gguZAvzEpPUgtxNkztSa8lWhQEGQ+EmPQ0n7w19qjThFU+OfEEd0uvlw==
|
||||
|
||||
"@turbo/windows-64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.11.5.tgz#60bdaaa7e8ba32f32623b31a1c75f6c3db400a0c"
|
||||
integrity sha512-AFkbPmXNd8XgCi/66YEHR2bVl3gtusXyDihTIVEPAuSB+7ulLmJePvz1unWPVKLHdbCTTIdKq2mpJ1B1e3lJXw==
|
||||
"@turbo/windows-64@2.11.10":
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-64/-/windows-64-2.11.10.tgz#fb29a315a112afac9f01a4a1ea7601b28f140f31"
|
||||
integrity sha512-UJOP3BkbreY3/JlMgJR6ZiimxYIBn+c+B4tnBIxD0E9c3Estmk+XoVNVBNBottZLNRXe3/ScgOU4+/k8tIWysg==
|
||||
|
||||
"@turbo/windows-arm64@2.11.5":
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.11.5.tgz#18bd5a0ad30c4e094995786bd78916b6de6b5c1c"
|
||||
integrity sha512-52ziXYP3snGOxjv19xA3WL1WDKv9Q6uDAa/7EKIR5j4hocMlYURkrOHbJ2ZZI+VfPU5MwKpcGeRfHxHJu7Y/Pw==
|
||||
"@turbo/windows-arm64@2.11.10":
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.11.10.tgz#bac3077b0a39643043ece360ff1f4f2af9272106"
|
||||
integrity sha512-xOkEjF3ws/b/YNlc+bvJHMYBn+vL+QbEISZFVi0qS+ImmhA1X3/gmNmBrjjIG3dg6aeN+n6gQitzSkXQ286qJQ==
|
||||
|
||||
"@types/esrecurse@^4.3.1":
|
||||
version "4.3.1"
|
||||
@@ -247,110 +252,100 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz#d404bac4cb04e4dcd20086e46e13c952bd0771e0"
|
||||
integrity sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==
|
||||
"@typescript-eslint/eslint-plugin@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz#95e29de63ce7ed793eb1c6efd9f8b802ce808262"
|
||||
integrity sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/type-utils" "8.70.1"
|
||||
"@typescript-eslint/utils" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/scope-manager" "8.72.1"
|
||||
"@typescript-eslint/type-utils" "8.72.1"
|
||||
"@typescript-eslint/utils" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.70.1.tgz#6c99b7b954efc05dae179001c8b477d1a8948dcf"
|
||||
integrity sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==
|
||||
"@typescript-eslint/parser@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.72.1.tgz#7370b68dadc3819b9aac68ea340bd8b43ee62379"
|
||||
integrity sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/scope-manager" "8.72.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/typescript-estree" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.70.1.tgz#d5635594cc8a573f0e15a772b1704e93a00134c1"
|
||||
integrity sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==
|
||||
"@typescript-eslint/project-service@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.72.1.tgz#1d09902a9c23f6cfab779e5acc78a45abf540add"
|
||||
integrity sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.70.1"
|
||||
"@typescript-eslint/types" "^8.70.1"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.72.1"
|
||||
"@typescript-eslint/types" "^8.72.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz#0feeae4da2d279b960cdbfe5e0a02fbf1195e93f"
|
||||
integrity sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==
|
||||
"@typescript-eslint/scope-manager@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz#a0fb4dfe5aa2b0149b7a2d1fd00d947992b13067"
|
||||
integrity sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz#e9353a6e5f3498a29b40946ca3e11f6166fef09e"
|
||||
integrity sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==
|
||||
"@typescript-eslint/tsconfig-utils@8.72.1", "@typescript-eslint/tsconfig-utils@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz#07394c85095ee38455e76bc221ba23650af73e44"
|
||||
integrity sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@^8.70.1":
|
||||
version "8.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz#83a69c9a514af8f5ff099cc6c0d609c273e8f039"
|
||||
integrity sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz#6ea36f2c7dc0776c1c8e1c380b4896f00bc4c27f"
|
||||
integrity sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==
|
||||
"@typescript-eslint/type-utils@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz#f1f592f63bd9726de21191aae3ccf64a813914b8"
|
||||
integrity sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/utils" "8.70.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/typescript-estree" "8.72.1"
|
||||
"@typescript-eslint/utils" "8.72.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.1.tgz#2022b9d07d057174eba25b9bbd289641d262a196"
|
||||
integrity sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==
|
||||
"@typescript-eslint/types@8.72.1", "@typescript-eslint/types@^8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.72.1.tgz#067d8f738c294611e33a1425ae43ac9b9c2f24e3"
|
||||
integrity sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==
|
||||
|
||||
"@typescript-eslint/types@^8.70.1":
|
||||
version "8.71.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.71.0.tgz#a230a121e71665060afe27d1f1ad110371f9451e"
|
||||
integrity sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz#ced48fee6f7043835d2edfed9786ae74813a3c99"
|
||||
integrity sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==
|
||||
"@typescript-eslint/typescript-estree@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz#e93b9cc3a56bf12f5fed857bb8f4963cce7924ee"
|
||||
integrity sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.70.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/visitor-keys" "8.70.1"
|
||||
"@typescript-eslint/project-service" "8.72.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.72.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/visitor-keys" "8.72.1"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.70.1.tgz#6a97887cee702df513692f5cdc7f79baeef48e02"
|
||||
integrity sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==
|
||||
"@typescript-eslint/utils@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.72.1.tgz#f210c1bf4a25aa1306df80c702ed30bbbc9b4d3d"
|
||||
integrity sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.70.1"
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/typescript-estree" "8.70.1"
|
||||
"@typescript-eslint/scope-manager" "8.72.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
"@typescript-eslint/typescript-estree" "8.72.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.70.1":
|
||||
version "8.70.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz#b4cde0ed16982bd963a6310fcae554f59fd14124"
|
||||
integrity sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==
|
||||
"@typescript-eslint/visitor-keys@8.72.1":
|
||||
version "8.72.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz#c7aec9883b44ef795f1b8200df81d9d44d355de7"
|
||||
integrity sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.70.1"
|
||||
"@typescript-eslint/types" "8.72.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@typescript/typescript-aix-ppc64@7.0.2":
|
||||
@@ -890,13 +885,13 @@ eslint-plugin-no-instanceof@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-no-instanceof/-/eslint-plugin-no-instanceof-1.0.1.tgz#5d9fc86d160df6991b654b294a62390207f1bb97"
|
||||
integrity sha512-zlqQ7EsfzbRO68uI+p8FIE7zYB4njs+nNbkNjSb5QmLi2et67zQLqSeaao5U9SpnlZTTJC87nS2oyHo2ACtajw==
|
||||
|
||||
eslint-plugin-prettier@^5.5.6:
|
||||
version "5.5.6"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
|
||||
integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
|
||||
eslint-plugin-prettier@^5.5.7:
|
||||
version "5.5.7"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz#ebef3acc2d664333b1356b717780198abc635398"
|
||||
integrity sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.13"
|
||||
synckit "^0.12.3"
|
||||
|
||||
eslint-scope@^9.1.2:
|
||||
version "9.1.2"
|
||||
@@ -923,10 +918,10 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||
|
||||
eslint@^10.11.0:
|
||||
version "10.11.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.11.0.tgz#304d1591b7c6a327e3f64b4f550f99fda160ae20"
|
||||
integrity sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==
|
||||
eslint@^10.13.0:
|
||||
version "10.13.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.13.0.tgz#a8a4742648c0fa9131f429b509de5407682d4864"
|
||||
integrity sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
@@ -1476,10 +1471,10 @@ keyv@^5.6.0:
|
||||
dependencies:
|
||||
"@keyv/serialize" "^1.1.1"
|
||||
|
||||
"langsmith@>=0.5.0 <1.0.0":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.7.1.tgz#d721ad2e5211f9a5373e8a84eb4fed36b5621971"
|
||||
integrity sha512-Wjk90UjNoY5cBHMlNAC/eZx5clI8jnjBOBW8uJu8+MWBtx0QesNjsUiLtjI+I3UnrpxFFpDqGXcnhBjH654Mqg==
|
||||
"langsmith@>=0.11.0 <1.0.0":
|
||||
version "0.11.6"
|
||||
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.11.6.tgz#cdc0f89a14632f8b172deea5daa1ee625f7442c3"
|
||||
integrity sha512-5hfJM5DEZpXFxUS3U9nY2CMyBRmFnhkJj7znUODJ8vsr/Jx38tGJl4GIhV484xAY7yQFWrFOTDr33PB95kHUKg==
|
||||
dependencies:
|
||||
p-queue "6.6.2"
|
||||
|
||||
@@ -1707,10 +1702,10 @@ prettier-linter-helpers@^1.0.1:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.9.9:
|
||||
version "3.9.9"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.9.tgz#09b826918c91cd4cbc80e0cbd1d2a922ff04f233"
|
||||
integrity sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==
|
||||
prettier@^3.9.10:
|
||||
version "3.9.10"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.10.tgz#ac378e432f1a7381518f2e080816bbaefa38d585"
|
||||
integrity sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==
|
||||
|
||||
punycode@^2.1.0:
|
||||
version "2.3.1"
|
||||
@@ -1940,11 +1935,12 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
synckit@^0.11.13:
|
||||
version "0.11.13"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
|
||||
integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
|
||||
synckit@^0.12.3:
|
||||
version "0.12.3"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.12.3.tgz#6e30a3441d095ed04c311077350ce205465512d6"
|
||||
integrity sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==
|
||||
dependencies:
|
||||
"@dual-bundle/import-meta-resolve" "^4.2.1"
|
||||
"@pkgr/core" "^0.3.6"
|
||||
|
||||
tinyglobby@^0.2.15:
|
||||
@@ -1970,17 +1966,17 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
turbo@^2.11.5:
|
||||
version "2.11.5"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.11.5.tgz#e7223abd0555b978d3efe1b00ea9a98f43afb7ab"
|
||||
integrity sha512-qLXheqz3TUvJpHIV6MUBwkNkvwKxMVG/QsimyiKT9DfNWPHJ294G/i5nDaH+fbHJt3UH7orubNNxAqYkbUNGAQ==
|
||||
turbo@^2.11.10:
|
||||
version "2.11.10"
|
||||
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.11.10.tgz#8b1d40debab2b871ff9e908d6f3018aeda04879d"
|
||||
integrity sha512-3GJYwig8ygEI89oaOZdduJX8/aw6O9eRPYHoIt6T3FZl9hkKjiyIspufJoVgqXihPoJu8M5CSV0DlZvzfohBCw==
|
||||
optionalDependencies:
|
||||
"@turbo/darwin-64" "2.11.5"
|
||||
"@turbo/darwin-arm64" "2.11.5"
|
||||
"@turbo/linux-64" "2.11.5"
|
||||
"@turbo/linux-arm64" "2.11.5"
|
||||
"@turbo/windows-64" "2.11.5"
|
||||
"@turbo/windows-arm64" "2.11.5"
|
||||
"@turbo/darwin-64" "2.11.10"
|
||||
"@turbo/darwin-arm64" "2.11.10"
|
||||
"@turbo/linux-64" "2.11.10"
|
||||
"@turbo/linux-arm64" "2.11.10"
|
||||
"@turbo/windows-64" "2.11.10"
|
||||
"@turbo/windows-arm64" "2.11.10"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
@@ -2148,6 +2144,6 @@ yocto-queue@^0.1.0:
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
"zod@^3.25.76 || ^4":
|
||||
version "4.3.6"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
|
||||
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
|
||||
version "4.6.7"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.7.tgz#e806cf64a7f4e37635ce9f2f52c04c3766f75e5b"
|
||||
integrity sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==
|
||||
@@ -2431,7 +2431,6 @@ def _deploy_cmd(
|
||||
validate_deploy_commands(install_command, build_command)
|
||||
agent = None
|
||||
if agent_id is not None or environment is not None:
|
||||
em.note("Note: --agent-id and --agent-environment flags are in private beta")
|
||||
if not agent_id or not agent_id.strip() or not environment:
|
||||
raise click.UsageError(
|
||||
"--agent-id and --agent-environment are required together."
|
||||
|
||||
@@ -85,6 +85,8 @@ def test_agent_create(deployment_api, tmp_path, monkeypatch):
|
||||
assert "name" not in payload
|
||||
assert build.call_args.kwargs["deployment_id"] == "runtime-id"
|
||||
assert "server-name" in result.output
|
||||
assert "'langgraph deploy' is in beta" in result.output
|
||||
assert "private beta" not in result.output
|
||||
assert (tmp_path / ".env").read_text() == "LANGSMITH_DEPLOYMENT_NAME=legacy\n"
|
||||
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -250,7 +250,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.11"
|
||||
version = "1.2.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -260,9 +260,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "xxhash" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/10/329dc49ce3fc40e36faafd407f08607fe3fab75613e709a66758b53faac2/langgraph-1.2.19.tar.gz", hash = "sha256:4a00fa1aad2b5fc2d3a789a8e9eaf993f76218ca924b434902ecf1bbaaf300f6", size = 782618, upload-time = "2026-10-30T22:23:32.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/9e/bd29e368fb16feea8d24270d377d56c34b49afea371717124f19cdfa890c/langgraph-1.2.19-py3-none-any.whl", hash = "sha256:6094a5cbed52a77d8c74c276fcd2056abde7b2836eab1c7a6b74bacbd1a269b3", size = 262285, upload-time = "2026-10-30T22:23:31.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -308,7 +308,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.4.4"
|
||||
version = "0.4.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -317,9 +317,9 @@ dependencies = [
|
||||
{ name = "orjson" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/a8/d5807df5976f2b8e181baddbddf857cc0ba501bba274e81711b10385c65b/langgraph_sdk-0.4.11.tar.gz", hash = "sha256:2550af3bc7cb78ac4facb97d141ec1a76568fa3eb3d29040cff70b45bda84740", size = 366656, upload-time = "2026-10-30T22:17:49.202Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/88/69899bb20b96b80468e98b90377e605c36658125e7e7fba401868a86138e/langgraph_sdk-0.4.11-py3-none-any.whl", hash = "sha256:6a65be4a8f825966a07317b8e34e3337cd227b9b58b185f0123fd13c3d0116fa", size = 169311, upload-time = "2026-10-30T22:17:47.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+10
-10
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
@@ -224,7 +224,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.11"
|
||||
version = "1.2.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -234,22 +234,22 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "xxhash" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/10/329dc49ce3fc40e36faafd407f08607fe3fab75613e709a66758b53faac2/langgraph-1.2.19.tar.gz", hash = "sha256:4a00fa1aad2b5fc2d3a789a8e9eaf993f76218ca924b434902ecf1bbaaf300f6", size = 782618, upload-time = "2026-10-30T22:23:32.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/9e/bd29e368fb16feea8d24270d377d56c34b49afea371717124f19cdfa890c/langgraph-1.2.19-py3-none-any.whl", hash = "sha256:6094a5cbed52a77d8c74c276fcd2056abde7b2836eab1c7a6b74bacbd1a269b3", size = 262285, upload-time = "2026-10-30T22:23:31.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "4.1.1"
|
||||
version = "4.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "ormsgpack" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/c4/f4107f50dd07169d08d95766cd69104231e694f44a1b25b68ad8b393712d/langgraph_checkpoint-4.3.1.tar.gz", hash = "sha256:625e7c78a72b33e7c47f7e87ca051060fb90aa66f5fd551c472c08376fb669cb", size = 182679, upload-time = "2026-10-26T19:57:31.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/40/4251d676b5c585d42175c901175d4bff56e22939625e18bcef781fc3e893/langgraph_checkpoint-4.3.1-py3-none-any.whl", hash = "sha256:215a826935c8db4d9676e79fd95943e5b3847d78604390898e1d98581cc594e5", size = 58093, upload-time = "2026-10-26T19:57:30.361Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -267,7 +267,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.4.4"
|
||||
version = "0.4.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -276,9 +276,9 @@ dependencies = [
|
||||
{ name = "orjson" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/a8/d5807df5976f2b8e181baddbddf857cc0ba501bba274e81711b10385c65b/langgraph_sdk-0.4.11.tar.gz", hash = "sha256:2550af3bc7cb78ac4facb97d141ec1a76568fa3eb3d29040cff70b45bda84740", size = 366656, upload-time = "2026-10-30T22:17:49.202Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/88/69899bb20b96b80468e98b90377e605c36658125e7e7fba401868a86138e/langgraph_sdk-0.4.11-py3-none-any.whl", hash = "sha256:6a65be4a8f825966a07317b8e34e3337cd227b9b58b185f0123fd13c3d0116fa", size = 169311, upload-time = "2026-10-30T22:17:47.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+31
-16
@@ -992,9 +992,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "langchain-protocol" },
|
||||
{ name = "langsmith" },
|
||||
@@ -1005,26 +1007,26 @@ dependencies = [
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/90/f932d9c82d15033ae9676fb3670993e10e1b4d9c59fe1b7ae024dee11955/langchain_core-1.7.3.tar.gz", hash = "sha256:77f70b81e238c981bfd8405840b03fdf143735ac93c0729c2bdb39d6f0648024", size = 1029116, upload-time = "2026-10-29T20:55:56.42Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c6/88bcc77899601e09a428d9c764e41ac3e7ede161384f85b9a2a8a828f81d/langchain_core-1.7.3-py3-none-any.whl", hash = "sha256:460c214bf31495d7b7e0a879e408dbfda677fd53fd879da786c1b966a4f6bff6", size = 577074, upload-time = "2026-10-29T20:55:54.722Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.17"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/b3/4e2429876c7a35585618caa2b9f9089f7162a6b50562b614ad82ac11c17e/langchain_protocol-0.0.17.tar.gz", hash = "sha256:e7cbe58c205df4b4fd87dc6d5bb23f10e13b236d0e2e1b0b9d05bc2b648f3eea", size = 6026, upload-time = "2026-06-12T18:39:51.923Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/8b/d908d082c3cf5368ddd1816ed114de79c873f979ba7f98b95fab0e841643/langchain_protocol-0.0.20.tar.gz", hash = "sha256:0f5bd002474265648e91161771a9e0438fe9480bd55a416e51af0f60070637ec", size = 6280, upload-time = "2026-10-20T11:13:50.84Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0a/a1bfe72c6ec856e99773bbd96c8086421e554b3693d0142b9ea009c6ac92/langchain_protocol-0.0.17-py3-none-any.whl", hash = "sha256:982a08fe152586ed10d4ff3d538c2e0b5766e5f307cdea325e10be3f2c17cae6", size = 7096, upload-time = "2026-06-12T18:39:50.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/4b/f1c99a0b27c5ff8b4b131c0d6e62012c17cc424089efe213767db927afd2/langchain_protocol-0.0.20-py3-none-any.whl", hash = "sha256:3c935e5e46b0f6f9cb52f45ba7cfa53d1b9fa80c82c4a277d47f03e07f7385b1", size = 7347, upload-time = "2026-10-20T11:13:49.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.3"
|
||||
version = "1.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1034,9 +1036,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "xxhash" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/55/e42f59b5e4cce75085f68abb77114c4d1152628157bd60c2587881bca559/langgraph-1.2.3.tar.gz", hash = "sha256:7f89cd5f0946fe29bd7ca048e2a84d3c14e7f652e38bb98e00f0ba8b7004b9d0", size = 718812, upload-time = "2026-06-01T18:55:54.458Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/3f/6e60789eab2c5ceaf34ba2c626ac345df93b026bbe7385018aa4b9c30397/langgraph-1.2.14.tar.gz", hash = "sha256:5b1a72b7a7f994ac6947b7751fbbe00e949f8bb44d9ab78881604b703247ae55", size = 752793, upload-time = "2026-10-06T14:41:19.789Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/9b/287f11be799f2f4dd289ab19d23788df22ac20ce884a010d0696f79016db/langgraph-1.2.3-py3-none-any.whl", hash = "sha256:22e6c89084adb3edb7855f6ffa692af3d75925f9c70deb43b86da14db1b02c78", size = 244841, upload-time = "2026-06-01T18:55:52.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4a/f976df0d3cf7fb7986dc39f656c6b7b5655834430e9bdfaef1e1e4fffaba/langgraph-1.2.14-py3-none-any.whl", hash = "sha256:e3f5f8ff4259f9281b73ff50fc9ef5cc3b025b8936b8283ecdca7cc0c4128690", size = 255980, upload-time = "2026-10-06T14:41:18.129Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1209,7 +1211,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1218,30 +1220,34 @@ dependencies = [
|
||||
{ name = "orjson" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9c/7ff305b366ce986052a27dc5946839bef2823b3567664cbad22e294dff5a/langgraph_sdk-0.4.5.tar.gz", hash = "sha256:d49a98a2ee8e0c494b101a7caf8061c8b4b94d3ee5ada0ea2dbe50903a5487b1", size = 349783, upload-time = "2026-09-21T14:42:55.732Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/85/54edf4902c0378510f09a009f64e3d3f5644aeeb51d13cf67ec380a5245b/langgraph_sdk-0.4.6.tar.gz", hash = "sha256:ea5b1749d70987d741c9b9fe2b7e1c5f7da7e4d102f64e390333c1890c9bbd5b", size = 350632, upload-time = "2026-10-06T14:17:53.641Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/71a6499f3481bc00bf4f42a17cb81caf19a02fce762db356626d7214e2c2/langgraph_sdk-0.4.5-py3-none-any.whl", hash = "sha256:e03ef033a20d21288af496e9b6c08ae3afcc1b58dc5abcd37575bfd1a93045de", size = 162304, upload-time = "2026-09-21T14:42:54.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c7/69d13026db3577c157877dea47a9dc5fffd3dcde2d1ab8009fa8f7e5f6a6/langgraph_sdk-0.4.6-py3-none-any.whl", hash = "sha256:57122b02bda41d37a97a5e09eb018aa8c9b3dc85c433880073779bd9324b7eea", size = 162390, upload-time = "2026-10-06T14:17:52.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.8.18"
|
||||
version = "0.14.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "uuid-utils" },
|
||||
{ name = "websockets" },
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/45/576803a72da96d9c0866b3815f08f447ed16c14acb52174129daca6847d6/langsmith-0.14.16.tar.gz", hash = "sha256:4e5816cbe1541f4d04d71f652eb8f4137889861bebcf7137bcba3553ae9e335e", size = 5096529, upload-time = "2026-10-29T17:53:27.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3a/9a072c02a22089ddf20f0f33ea95bee5a5951b31839f118b04f27b94be6e/langsmith-0.14.16-py3-none-any.whl", hash = "sha256:b9ea1a57622b0788b181d78605f974fb206c5f6f75793076ffec23cef4d904c8", size = 933680, upload-time = "2026-10-29T17:53:25.465Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2093,6 +2099,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.4"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import enum
|
||||
import inspect
|
||||
import logging
|
||||
@@ -23,9 +24,16 @@ from typing import (
|
||||
Optional,
|
||||
Protocol,
|
||||
TypeGuard,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler, BaseCallbackManager
|
||||
from langchain_core.callbacks.manager import (
|
||||
AsyncCallbackManagerForChainRun,
|
||||
BaseRunManager,
|
||||
CallbackManagerForChainRun,
|
||||
)
|
||||
from langchain_core.runnables.base import (
|
||||
Runnable,
|
||||
RunnableConfig,
|
||||
@@ -41,6 +49,7 @@ from langchain_core.runnables.config import (
|
||||
var_child_runnable_config,
|
||||
)
|
||||
from langchain_core.runnables.utils import Input, Output
|
||||
from langchain_core.tracers.base import BaseTracer
|
||||
from langchain_core.tracers.langchain import LangChainTracer
|
||||
from langgraph.store.base import BaseStore
|
||||
|
||||
@@ -57,7 +66,7 @@ from langgraph._internal._constants import (
|
||||
)
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.errors import NodeError
|
||||
from langgraph.types import StreamWriter
|
||||
from langgraph.types import StreamWriter, TracePolicy
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
@@ -85,6 +94,29 @@ def _trace_payload(value: Any, transform: Callable[[Any], Any] | None) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _is_stream_handler(handler: BaseCallbackHandler) -> bool:
|
||||
# Inheritance, not isinstance on the runtime-checkable protocol, which also
|
||||
# matches any exporter that merely has the tap methods: stream handlers get a
|
||||
# node's real payload and its streamed output.
|
||||
return _StreamingCallbackHandler in type(handler).__mro__
|
||||
|
||||
|
||||
_M = TypeVar("_M", bound=BaseCallbackManager | BaseRunManager)
|
||||
|
||||
|
||||
def _split_handlers(manager: _M) -> tuple[_M, _M]:
|
||||
# The recorded payload exists to keep tracer ingest small, so only tracers get it.
|
||||
# `astream_log`'s handler is a tracer too, but it feeds the caller's own stream.
|
||||
real, tracers = copy.copy(manager), copy.copy(manager)
|
||||
tracers.handlers = [
|
||||
h
|
||||
for h in manager.handlers
|
||||
if isinstance(h, BaseTracer) and not _is_stream_handler(h)
|
||||
]
|
||||
real.handlers = [h for h in manager.handlers if h not in tracers.handlers]
|
||||
return real, tracers
|
||||
|
||||
|
||||
def _set_config_context(
|
||||
config: RunnableConfig, run: Any = None
|
||||
) -> Token[RunnableConfig | None]:
|
||||
@@ -593,13 +625,15 @@ class RunnableSeq(Runnable):
|
||||
*steps: RunnableLike,
|
||||
name: str | None = None,
|
||||
trace_inputs: Callable[[Any], Any] | None = None,
|
||||
trace_outputs: Callable[[Any], Any] | None = None,
|
||||
trace_policy: TracePolicy | None = None,
|
||||
) -> None:
|
||||
"""Create a new RunnableSeq.
|
||||
|
||||
Args:
|
||||
steps: The steps to include in the sequence.
|
||||
name: The name of the `Runnable`.
|
||||
trace_inputs: Transforms the input every callback handler receives.
|
||||
trace_policy: Processes what tracers record, after `trace_inputs`.
|
||||
|
||||
Raises:
|
||||
ValueError: If the sequence has less than 2 steps.
|
||||
@@ -619,7 +653,67 @@ class RunnableSeq(Runnable):
|
||||
self.steps = steps_flat
|
||||
self.name = name
|
||||
self.trace_inputs = trace_inputs
|
||||
self.trace_outputs = trace_outputs
|
||||
self.trace_policy = trace_policy or TracePolicy()
|
||||
|
||||
def _start(self, input: Any, config: RunnableConfig) -> CallbackManagerForChainRun:
|
||||
callback_manager = get_callback_manager_for_config(config)
|
||||
payload = _trace_payload(input, self.trace_inputs)
|
||||
recorded = _trace_payload(payload, self.trace_policy.process_inputs)
|
||||
name = config.get("run_name") or self.get_name()
|
||||
run_id = config.pop("run_id", None)
|
||||
if recorded is payload:
|
||||
return callback_manager.on_chain_start(
|
||||
None, payload, name=name, run_id=run_id
|
||||
)
|
||||
real, tracers = _split_handlers(callback_manager)
|
||||
run_manager = real.on_chain_start(None, payload, name=name, run_id=run_id)
|
||||
tracers.on_chain_start(None, recorded, name=name, run_id=run_manager.run_id)
|
||||
run_manager.handlers = callback_manager.handlers
|
||||
return run_manager
|
||||
|
||||
async def _astart(
|
||||
self, input: Any, config: RunnableConfig
|
||||
) -> AsyncCallbackManagerForChainRun:
|
||||
callback_manager = get_async_callback_manager_for_config(config)
|
||||
payload = _trace_payload(input, self.trace_inputs)
|
||||
recorded = _trace_payload(payload, self.trace_policy.process_inputs)
|
||||
name = config.get("run_name") or self.get_name()
|
||||
run_id = config.pop("run_id", None)
|
||||
if recorded is payload:
|
||||
return await callback_manager.on_chain_start(
|
||||
None, payload, name=name, run_id=run_id
|
||||
)
|
||||
real, tracers = _split_handlers(callback_manager)
|
||||
# Tracers start last, so a cancellation before then leaves no tracer run open.
|
||||
run_manager = await real.on_chain_start(None, payload, name=name, run_id=run_id)
|
||||
await tracers.on_chain_start(
|
||||
None, recorded, name=name, run_id=run_manager.run_id
|
||||
)
|
||||
run_manager.handlers = callback_manager.handlers
|
||||
return run_manager
|
||||
|
||||
def _end(self, run_manager: CallbackManagerForChainRun, output: Any) -> None:
|
||||
recorded = _trace_payload(output, self.trace_policy.process_outputs)
|
||||
if recorded is output:
|
||||
run_manager.on_chain_end(output)
|
||||
else:
|
||||
real, tracers = _split_handlers(run_manager)
|
||||
tracers.on_chain_end(recorded)
|
||||
real.on_chain_end(output)
|
||||
|
||||
async def _aend(
|
||||
self, run_manager: AsyncCallbackManagerForChainRun, output: Any
|
||||
) -> None:
|
||||
recorded = _trace_payload(output, self.trace_policy.process_outputs)
|
||||
if recorded is output:
|
||||
await run_manager.on_chain_end(output)
|
||||
else:
|
||||
real, tracers = _split_handlers(run_manager)
|
||||
# Tracers end first: on_chain_end is shielded once it starts, so a
|
||||
# cancellation can only skip the other handlers' end, never leave a
|
||||
# tracer run open.
|
||||
await tracers.on_chain_end(recorded)
|
||||
await real.on_chain_end(output)
|
||||
|
||||
def __or__(
|
||||
self,
|
||||
@@ -676,15 +770,8 @@ class RunnableSeq(Runnable):
|
||||
) -> Any:
|
||||
if config is None:
|
||||
config = ensure_config()
|
||||
# setup callbacks and context
|
||||
callback_manager = get_callback_manager_for_config(config)
|
||||
# start the root run
|
||||
run_manager = callback_manager.on_chain_start(
|
||||
None,
|
||||
_trace_payload(input, self.trace_inputs),
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
run_manager = self._start(input, config)
|
||||
# invoke all steps in sequence
|
||||
try:
|
||||
for i, step in enumerate(self.steps):
|
||||
@@ -712,7 +799,7 @@ class RunnableSeq(Runnable):
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
run_manager.on_chain_end(_trace_payload(input, self.trace_outputs))
|
||||
self._end(run_manager, input)
|
||||
return input
|
||||
|
||||
async def ainvoke(
|
||||
@@ -723,15 +810,8 @@ class RunnableSeq(Runnable):
|
||||
) -> Any:
|
||||
if config is None:
|
||||
config = ensure_config()
|
||||
# setup callbacks
|
||||
callback_manager = get_async_callback_manager_for_config(config)
|
||||
# start the root run
|
||||
run_manager = await callback_manager.on_chain_start(
|
||||
None,
|
||||
_trace_payload(input, self.trace_inputs),
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
run_manager = await self._astart(input, config)
|
||||
|
||||
# invoke all steps in sequence
|
||||
try:
|
||||
@@ -765,7 +845,7 @@ class RunnableSeq(Runnable):
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
await run_manager.on_chain_end(_trace_payload(input, self.trace_outputs))
|
||||
await self._aend(run_manager, input)
|
||||
return input
|
||||
|
||||
def stream(
|
||||
@@ -776,15 +856,8 @@ class RunnableSeq(Runnable):
|
||||
) -> Iterator[Any]:
|
||||
if config is None:
|
||||
config = ensure_config()
|
||||
# setup callbacks
|
||||
callback_manager = get_callback_manager_for_config(config)
|
||||
# start the root run
|
||||
run_manager = callback_manager.on_chain_start(
|
||||
None,
|
||||
_trace_payload(input, self.trace_inputs),
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
run_manager = self._start(input, config)
|
||||
# get the run object
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
@@ -814,10 +887,9 @@ class RunnableSeq(Runnable):
|
||||
)
|
||||
iterator = step.transform(iterator, config)
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
if _StreamingCallbackHandler is not None:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, _StreamingCallbackHandler):
|
||||
iterator = h.tap_output_iter(run_manager.run_id, iterator)
|
||||
for h in run_manager.handlers:
|
||||
if _is_stream_handler(h):
|
||||
iterator = h.tap_output_iter(run_manager.run_id, iterator)
|
||||
# consume into final output
|
||||
output = context.run(_consume_iter, iterator)
|
||||
# sequence doesn't emit output, yield to mark as generator
|
||||
@@ -826,7 +898,7 @@ class RunnableSeq(Runnable):
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
run_manager.on_chain_end(_trace_payload(output, self.trace_outputs))
|
||||
self._end(run_manager, output)
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
@@ -836,15 +908,8 @@ class RunnableSeq(Runnable):
|
||||
) -> AsyncIterator[Any]:
|
||||
if config is None:
|
||||
config = ensure_config()
|
||||
# setup callbacks
|
||||
callback_manager = get_async_callback_manager_for_config(config)
|
||||
# start the root run
|
||||
run_manager = await callback_manager.on_chain_start(
|
||||
None,
|
||||
_trace_payload(input, self.trace_inputs),
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
run_manager = await self._astart(input, config)
|
||||
# stream the last steps
|
||||
# transform the input stream of each step with the next
|
||||
# steps that don't natively support transforming an input stream will
|
||||
@@ -880,12 +945,11 @@ class RunnableSeq(Runnable):
|
||||
if hasattr(aiterator, "aclose"):
|
||||
stack.push_async_callback(aiterator.aclose)
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
if _StreamingCallbackHandler is not None:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, _StreamingCallbackHandler):
|
||||
aiterator = h.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
for h in run_manager.handlers:
|
||||
if _is_stream_handler(h):
|
||||
aiterator = h.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
# consume into final output
|
||||
output = await asyncio.create_task(
|
||||
_consume_aiter(aiterator), context=context
|
||||
@@ -896,9 +960,7 @@ class RunnableSeq(Runnable):
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
await run_manager.on_chain_end(
|
||||
_trace_payload(output, self.trace_outputs)
|
||||
)
|
||||
await self._aend(run_manager, output)
|
||||
else:
|
||||
try:
|
||||
async with AsyncExitStack() as stack:
|
||||
@@ -914,12 +976,11 @@ class RunnableSeq(Runnable):
|
||||
if hasattr(aiterator, "aclose"):
|
||||
stack.push_async_callback(aiterator.aclose)
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
if _StreamingCallbackHandler is not None:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, _StreamingCallbackHandler):
|
||||
aiterator = h.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
for h in run_manager.handlers:
|
||||
if _is_stream_handler(h):
|
||||
aiterator = h.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
# consume into final output
|
||||
output = await _consume_aiter(aiterator)
|
||||
# sequence doesn't emit output, yield to mark as generator
|
||||
@@ -928,9 +989,7 @@ class RunnableSeq(Runnable):
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
await run_manager.on_chain_end(
|
||||
_trace_payload(output, self.trace_outputs)
|
||||
)
|
||||
await self._aend(run_manager, output)
|
||||
|
||||
|
||||
def _consume_iter(it: Iterator[Any]) -> Any:
|
||||
|
||||
@@ -4,6 +4,7 @@ from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
|
||||
from langgraph.channels.log import Log, LogRef, MessagesLog
|
||||
from langgraph.channels.named_barrier_value import (
|
||||
NamedBarrierValue,
|
||||
NamedBarrierValueAfterFinish,
|
||||
@@ -22,6 +23,9 @@ __all__ = (
|
||||
"EphemeralValue",
|
||||
"BinaryOperatorAggregate",
|
||||
"DeltaChannel",
|
||||
"Log",
|
||||
"LogRef",
|
||||
"MessagesLog",
|
||||
"NamedBarrierValue",
|
||||
"NamedBarrierValueAfterFinish",
|
||||
# topics
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import collections.abc
|
||||
import warnings
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Generic
|
||||
|
||||
@@ -29,6 +30,26 @@ def _strip_extras(t): # type: ignore[no-untyped-def]
|
||||
return t
|
||||
|
||||
|
||||
def _is_add_messages(operator: Callable) -> bool:
|
||||
"""Whether `operator` is `add_messages`, alone or with `format=`."""
|
||||
return getattr(operator, "_is_add_messages", False) or getattr(
|
||||
getattr(operator, "func", None), "_is_add_messages", False
|
||||
)
|
||||
|
||||
|
||||
def _warn_overwrite_messages() -> None:
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV13
|
||||
|
||||
warnings.warn(
|
||||
"Replacing all messages with `Overwrite` in an `add_messages` channel "
|
||||
"is deprecated: stored history will be append-only. Build the messages "
|
||||
"the model sees when calling it instead, and use the `MessagesLog` "
|
||||
"channel, which stores messages once instead of in every checkpoint.",
|
||||
category=LangGraphDeprecatedSinceV13,
|
||||
stacklevel=4,
|
||||
)
|
||||
|
||||
|
||||
def _get_overwrite(value: Any) -> tuple[bool, Any]:
|
||||
"""Inspects the given value and returns (is_overwrite, overwrite_value).
|
||||
|
||||
@@ -140,6 +161,8 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
|
||||
)
|
||||
raise InvalidUpdateError(msg)
|
||||
if _is_add_messages(self.operator):
|
||||
_warn_overwrite_messages()
|
||||
self.value = overwrite_value
|
||||
seen_overwrite = True
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Append-only log channels (transcript log prototype).
|
||||
|
||||
!!! warning "Prototype"
|
||||
|
||||
The transcript log is a prototype. Its API and storage may change.
|
||||
|
||||
A log channel is append-only and has no reducer: the only write is "append
|
||||
these entries". Its entries are not stored in checkpoints. A checkpoint's
|
||||
value for the channel is a marker (`LogStep`) for the step that produced its
|
||||
version, and the entries live in the saver's log storage, read with
|
||||
`BaseCheckpointSaver.read_log`.
|
||||
|
||||
Two channel types share that storage:
|
||||
|
||||
- `Log` holds JSON values. Anything that isn't JSON is rejected.
|
||||
- `MessagesLog` holds LangChain messages, stored as canonical JSON. Edit ops
|
||||
(`RemoveMessage`, `Overwrite`, re-adding an existing id) are rejected; a
|
||||
graph that needs a different model context builds it as a projection.
|
||||
|
||||
A thread whose field held a plain list before it became a log channel (for
|
||||
example `Annotated[list, add_messages]` changed to `MessagesLog()`) keeps
|
||||
working: the stored list becomes the log's first entries, the "seed". The
|
||||
seed is committed with the first checkpoint saved after the change, and
|
||||
checkpoints from before the change still read as the list they stored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from langgraph.checkpoint.serde.types import LogAppend, LogStep
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import _get_overwrite
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
__all__ = ("Log", "LogRef", "MessagesLog")
|
||||
|
||||
|
||||
class LogRef(NamedTuple):
|
||||
"""What state reads return for a log channel: the log's length at the
|
||||
checkpoint. Read the entries with `read_log`."""
|
||||
|
||||
length: int
|
||||
|
||||
|
||||
def _check_json(value: Any, path: str = "entry") -> None:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise InvalidUpdateError(f"Log {path} is not JSON: {value!r}")
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for i, item in enumerate(value):
|
||||
_check_json(item, f"{path}[{i}]")
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for k, item in value.items():
|
||||
if not isinstance(k, str):
|
||||
raise InvalidUpdateError(f"Log {path} has a non-string key: {k!r}")
|
||||
_check_json(item, f"{path}.{k}")
|
||||
return
|
||||
raise InvalidUpdateError(
|
||||
f"Log {path} is not JSON: {type(value).__name__} {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
||||
|
||||
|
||||
class Log(BaseChannel[list, list, LogStep]):
|
||||
"""An append-only log of JSON entries.
|
||||
|
||||
Write a list of entries to append them. Nodes read the whole log as a
|
||||
list. State reads return a `LogRef` instead of the entries.
|
||||
"""
|
||||
|
||||
__slots__ = ("entries", "base", "marker", "loaded", "extra", "seed", "seeding")
|
||||
|
||||
def __init__(self, typ: Any = list) -> None:
|
||||
super().__init__(typ)
|
||||
self.entries: list[Any] = []
|
||||
# length covered by the last committed step (the checkpoint's leaf)
|
||||
self.base = 0
|
||||
self.marker: LogStep | None = None
|
||||
# False when built from a checkpoint without reading the log
|
||||
self.loaded = True
|
||||
# entries appended to a channel that was not loaded
|
||||
self.extra = 0
|
||||
# True while the entries hold a plain list stored before the field
|
||||
# became a log channel, not committed to the log yet
|
||||
self.seed = False
|
||||
# True from the commit that includes the seed until the loop has
|
||||
# stamped that checkpoint's step
|
||||
self.seeding = False
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return type(self) is type(other)
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Any:
|
||||
return list
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Any:
|
||||
return list
|
||||
|
||||
# -- encoding ----------------------------------------------------------
|
||||
|
||||
def encode(self, entry: Any) -> Any:
|
||||
"""An entry as the JSON value stored for it."""
|
||||
_check_json(entry)
|
||||
return entry
|
||||
|
||||
def decode(self, data: Any) -> Any:
|
||||
"""A stored JSON value as the entry nodes see."""
|
||||
return data
|
||||
|
||||
def coerce(self, value: Any) -> list[Any]:
|
||||
"""A write as the list of entries it appends."""
|
||||
if not isinstance(value, list):
|
||||
raise InvalidUpdateError(
|
||||
f"Log channel {self.key!r} takes a list of entries, got "
|
||||
f"{type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
def append_value(self, value: Any, exec_: str) -> LogAppend:
|
||||
"""A write as the `LogAppend` handed to the saver."""
|
||||
self._reject_overwrite(value)
|
||||
texts = tuple(_dumps(self.encode(e)) for e in self.coerce(value))
|
||||
return LogAppend(self.base, texts, exec_)
|
||||
|
||||
# -- channel protocol --------------------------------------------------
|
||||
|
||||
def _new(self) -> Self:
|
||||
new = self.__class__.__new__(self.__class__)
|
||||
BaseChannel.__init__(new, self.typ, self.key)
|
||||
new.entries = []
|
||||
new.base = 0
|
||||
new.marker = None
|
||||
new.loaded = True
|
||||
new.extra = 0
|
||||
new.seed = False
|
||||
new.seeding = False
|
||||
return new
|
||||
|
||||
def copy(self) -> Self:
|
||||
new = self._new()
|
||||
new.entries = list(self.entries)
|
||||
new.base = self.base
|
||||
new.marker = self.marker
|
||||
new.loaded = self.loaded
|
||||
new.extra = self.extra
|
||||
new.seed = self.seed
|
||||
new.seeding = self.seeding
|
||||
return new
|
||||
|
||||
def from_checkpoint(self, checkpoint: Any) -> Self:
|
||||
"""A channel that knows its marker but not its entries.
|
||||
|
||||
State reads build channels this way; `get` then returns a `LogRef`.
|
||||
The loop builds log channels with `from_log` instead.
|
||||
|
||||
A plain list is what the field stored before it became a log channel.
|
||||
The channel then holds it as its seed: uncommitted entries that the
|
||||
next commit writes to the log, ahead of anything appended after.
|
||||
"""
|
||||
new = self._new()
|
||||
if isinstance(checkpoint, LogStep):
|
||||
new.marker = checkpoint.stripped()
|
||||
new.base = checkpoint.end
|
||||
new.loaded = False
|
||||
elif isinstance(checkpoint, list):
|
||||
new.entries = self._seed_entries(checkpoint)
|
||||
new.seed = bool(new.entries)
|
||||
elif checkpoint is not MISSING and checkpoint is not None:
|
||||
raise InvalidUpdateError(
|
||||
f"Log channel {self.key!r} can't start from {type(checkpoint).__name__}"
|
||||
)
|
||||
return new
|
||||
|
||||
def _seed_entries(self, stored: list[Any]) -> list[Any]:
|
||||
"""The entries a stored plain list becomes."""
|
||||
return list(stored)
|
||||
|
||||
def from_log(self, marker: Any, entries: Sequence[Any]) -> Self:
|
||||
"""A loaded channel: the marker, and the entries read from the log."""
|
||||
new = self._new()
|
||||
if isinstance(marker, LogStep):
|
||||
if len(entries) != marker.end:
|
||||
raise ValueError(
|
||||
f"Log channel {self.key!r}: read {len(entries)} entries, "
|
||||
f"the checkpoint has {marker.end}"
|
||||
)
|
||||
new.marker = marker.stripped()
|
||||
new.base = marker.end
|
||||
new.entries = [self.decode(e) for e in entries]
|
||||
return new
|
||||
|
||||
def get(self) -> Any:
|
||||
if not self.loaded:
|
||||
return LogRef(self.base + self.extra)
|
||||
if not self.entries:
|
||||
raise EmptyChannelError()
|
||||
return self.entries
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return (not self.loaded and self.base + self.extra > 0) or bool(self.entries)
|
||||
|
||||
def update(self, values: Sequence[Any]) -> bool:
|
||||
if not values:
|
||||
return False
|
||||
appended: list[Any] = []
|
||||
for value in values:
|
||||
if isinstance(value, LogAppend):
|
||||
# a write read back from the saver (pending writes on resume)
|
||||
appended.extend(self.decode(json.loads(t)) for t in value.entries)
|
||||
continue
|
||||
self._reject_overwrite(value)
|
||||
for entry in self.coerce(value):
|
||||
self.encode(entry) # validate before anything is applied
|
||||
appended.append(entry)
|
||||
if not appended:
|
||||
return False
|
||||
self._check_appends(appended)
|
||||
if not self.loaded:
|
||||
self.extra += len(appended)
|
||||
else:
|
||||
self.entries.extend(appended)
|
||||
return True
|
||||
|
||||
def _check_appends(self, appended: list[Any]) -> None:
|
||||
pass
|
||||
|
||||
def _reject_overwrite(self, value: Any) -> None:
|
||||
if _get_overwrite(value)[0]:
|
||||
raise InvalidUpdateError(
|
||||
f"Log channel {self.key!r} is append-only: Overwrite is not supported"
|
||||
)
|
||||
|
||||
def checkpoint(self) -> Any:
|
||||
return MISSING if self.marker is None else self.marker
|
||||
|
||||
def commit(self) -> LogStep | None:
|
||||
"""The marker for a checkpoint about to be saved.
|
||||
|
||||
Entries appended since the last commit become a new `LogStep`
|
||||
(carrying them inline; the loop strips them when they already reached
|
||||
the saver through `put_writes`), and the channel's base moves to its
|
||||
end.
|
||||
"""
|
||||
if self.loaded and len(self.entries) > self.base:
|
||||
texts = tuple(_dumps(self.encode(e)) for e in self.entries[self.base :])
|
||||
step = LogStep(self.base, len(self.entries) - self.base, "", texts)
|
||||
self.base = len(self.entries)
|
||||
self.marker = step.stripped()
|
||||
self.seeding, self.seed = self.seed, False
|
||||
return step
|
||||
return self.marker
|
||||
|
||||
|
||||
class MessagesLog(Log):
|
||||
"""An append-only log of LangChain messages.
|
||||
|
||||
Messages are stored as canonical JSON (`message_to_dict`). Every message
|
||||
has an id; writing an id that is already in the log is an error, as are
|
||||
`RemoveMessage` and `Overwrite`.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def encode(self, entry: Any) -> Any:
|
||||
from langchain_core.messages import BaseMessage, RemoveMessage, message_to_dict
|
||||
|
||||
if isinstance(entry, RemoveMessage):
|
||||
raise InvalidUpdateError(
|
||||
f"MessagesLog {self.key!r} is append-only: RemoveMessage is not "
|
||||
"supported. To give the model a different set of messages, build "
|
||||
"them when calling the model instead of changing stored history."
|
||||
)
|
||||
if not isinstance(entry, BaseMessage):
|
||||
raise InvalidUpdateError(
|
||||
f"MessagesLog {self.key!r} takes messages, got {type(entry).__name__}"
|
||||
)
|
||||
data = message_to_dict(entry)
|
||||
_check_json(data, "message")
|
||||
return data
|
||||
|
||||
def decode(self, data: Any) -> Any:
|
||||
from langchain_core.messages import messages_from_dict
|
||||
|
||||
return messages_from_dict([data])[0]
|
||||
|
||||
def _seed_entries(self, stored: list[Any]) -> list[Any]:
|
||||
# the list `add_messages` stored: already merged, so ids are unique
|
||||
# and there are no `RemoveMessage`s
|
||||
return self.coerce(stored)
|
||||
|
||||
def coerce(self, value: Any) -> list[Any]:
|
||||
from langchain_core.messages import convert_to_messages
|
||||
|
||||
items = value if isinstance(value, list) else [value]
|
||||
messages = convert_to_messages(items)
|
||||
for m in messages:
|
||||
if not m.id:
|
||||
m.id = str(uuid.uuid4())
|
||||
return messages
|
||||
|
||||
def _check_appends(self, appended: list[Any]) -> None:
|
||||
seen = {m.id for m in self.entries} if self.loaded else set()
|
||||
for m in appended:
|
||||
if m.id in seen:
|
||||
raise InvalidUpdateError(
|
||||
f"MessagesLog {self.key!r} already has a message with id "
|
||||
f"{m.id!r}: the log is append-only, so a message can't be "
|
||||
"replaced. Return only new messages from a node, not the "
|
||||
"whole list."
|
||||
)
|
||||
seen.add(m.id)
|
||||
@@ -22,12 +22,12 @@ from langchain_core.messages import (
|
||||
)
|
||||
from typing_extensions import TypedDict, deprecated
|
||||
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, NS_SEP
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_SEND
|
||||
from langgraph.graph.state import StateGraph
|
||||
from langgraph.pregel._messages import (
|
||||
_messages_delta_reducer as _messages_delta_reducer,
|
||||
)
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV10
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV10, LangGraphDeprecatedSinceV13
|
||||
|
||||
__all__ = (
|
||||
"add_messages",
|
||||
@@ -41,7 +41,27 @@ Messages = list[MessageLikeRepresentation] | MessageLikeRepresentation
|
||||
REMOVE_ALL_MESSAGES = "__remove_all__"
|
||||
|
||||
|
||||
_EDIT_DEPRECATION = (
|
||||
"{what} through `add_messages` is deprecated: stored history will be "
|
||||
"append-only. Build the messages the model sees when calling it instead "
|
||||
"(for example from a summary kept in state), and use the `MessagesLog` "
|
||||
"channel, which stores messages once instead of in every checkpoint."
|
||||
)
|
||||
|
||||
|
||||
def _warn_edit(what: str) -> None:
|
||||
warnings.warn(
|
||||
_EDIT_DEPRECATION.format(what=what),
|
||||
category=LangGraphDeprecatedSinceV13,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
def _add_messages_wrapper(func: Callable) -> Callable[[Messages, Messages], Messages]:
|
||||
# lets `BinaryOperatorAggregate` recognize the reducer, alone or with
|
||||
# `format=`, without importing this module
|
||||
func._is_add_messages = True # type: ignore[attr-defined]
|
||||
|
||||
def _add_messages(
|
||||
left: Messages | None = None, right: Messages | None = None, **kwargs: Any
|
||||
) -> Messages | Callable[[Messages, Messages], Messages]:
|
||||
@@ -57,6 +77,7 @@ def _add_messages_wrapper(func: Callable) -> Callable[[Messages, Messages], Mess
|
||||
return partial(func, **kwargs)
|
||||
|
||||
_add_messages.__doc__ = func.__doc__
|
||||
_add_messages._is_add_messages = True # type: ignore[attr-defined]
|
||||
return cast(Callable[[Messages, Messages], Messages], _add_messages)
|
||||
|
||||
|
||||
@@ -213,9 +234,11 @@ def add_messages(
|
||||
remove_all_idx = idx
|
||||
|
||||
if remove_all_idx is not None:
|
||||
_warn_edit("Removing all messages with `REMOVE_ALL_MESSAGES`")
|
||||
return right[remove_all_idx + 1 :]
|
||||
|
||||
# merge
|
||||
left_ids = {m.id for m in left}
|
||||
merged = left.copy()
|
||||
merged_by_id = {m.id: i for i, m in enumerate(merged)}
|
||||
ids_to_remove = set()
|
||||
@@ -235,6 +258,10 @@ def add_messages(
|
||||
merged_by_id[m.id] = len(merged)
|
||||
merged.append(m)
|
||||
merged = [m for m in merged if m.id not in ids_to_remove]
|
||||
if any(isinstance(m, RemoveMessage) for m in right):
|
||||
_warn_edit("Removing messages with `RemoveMessage`")
|
||||
elif any(m.id in left_ids for m in right):
|
||||
_warn_edit("Replacing a message by id")
|
||||
|
||||
if format == "langchain-openai":
|
||||
merged = _format_messages(merged)
|
||||
@@ -343,7 +370,7 @@ def push_message(
|
||||
)
|
||||
|
||||
from langgraph.config import get_config
|
||||
from langgraph.pregel._messages import StreamMessagesHandler
|
||||
from langgraph.pregel._messages import StreamMessagesHandler, graph_namespace
|
||||
|
||||
config = get_config()
|
||||
message = next(x for x in convert_to_messages([message]))
|
||||
@@ -359,15 +386,12 @@ def push_message(
|
||||
):
|
||||
handlers = config["callbacks"]
|
||||
|
||||
if stream_handler := next(
|
||||
(x for x in handlers if isinstance(x, StreamMessagesHandler)), None
|
||||
):
|
||||
metadata = config["metadata"]
|
||||
message_meta = (
|
||||
tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP)),
|
||||
metadata,
|
||||
)
|
||||
stream_handler._emit(message_meta, message, dedupe=False)
|
||||
metadata = config["metadata"]
|
||||
for handler in handlers:
|
||||
if isinstance(handler, StreamMessagesHandler):
|
||||
ns = graph_namespace(metadata)
|
||||
if handler.subgraphs or not ns or ns == handler.parent_ns:
|
||||
handler._emit((ns, metadata), message, dedupe=False)
|
||||
|
||||
if state_key:
|
||||
config[CONF][CONFIG_KEY_SEND]([(state_key, message)])
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from inspect import signature
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
@@ -16,7 +16,7 @@ from langgraph.checkpoint.base import (
|
||||
PendingWrite,
|
||||
)
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||
from langgraph.checkpoint.serde.types import LogStep, _DeltaSnapshot
|
||||
|
||||
from langgraph._internal._config import (
|
||||
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT,
|
||||
@@ -35,6 +35,7 @@ from langgraph._internal._typing import MISSING
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate, _get_overwrite
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.log import Log
|
||||
from langgraph.managed.base import ManagedValueMapping, ManagedValueSpec
|
||||
|
||||
LATEST_VERSION = 4
|
||||
@@ -325,6 +326,10 @@ def create_checkpoint(
|
||||
bumped[k] = (old, channel_versions[k])
|
||||
values[k] = _DeltaSnapshot(ch.get())
|
||||
else:
|
||||
if isinstance(ch, Log):
|
||||
if (log_step := ch.commit()) is not None:
|
||||
values[k] = log_step
|
||||
continue
|
||||
v = ch.checkpoint()
|
||||
if v is not MISSING:
|
||||
values[k] = v
|
||||
@@ -455,12 +460,39 @@ def _require_saver_for_history(
|
||||
)
|
||||
|
||||
|
||||
def _log_specs(
|
||||
specs: Mapping[str, BaseChannel], checkpoint: Checkpoint, load_logs: bool
|
||||
) -> list[str]:
|
||||
"""Log channels whose entries must be read from the saver."""
|
||||
if not load_logs:
|
||||
return []
|
||||
return [
|
||||
k
|
||||
for k, spec in specs.items()
|
||||
if isinstance(spec, Log)
|
||||
and isinstance(checkpoint["channel_values"].get(k), LogStep)
|
||||
]
|
||||
|
||||
|
||||
def _require_saver_for_log(
|
||||
log_channels: list[str],
|
||||
saver: BaseCheckpointSaver | None,
|
||||
config: RunnableConfig | None,
|
||||
) -> None:
|
||||
if log_channels and (saver is None or config is None):
|
||||
raise ValueError(
|
||||
f"Log channels {log_channels} have entries to read but no "
|
||||
"checkpointer or config was passed to read them"
|
||||
)
|
||||
|
||||
|
||||
def channels_from_checkpoint(
|
||||
specs: Mapping[str, BaseChannel | ManagedValueSpec],
|
||||
checkpoint: Checkpoint,
|
||||
*,
|
||||
saver: BaseCheckpointSaver | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
load_logs: bool = True,
|
||||
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
|
||||
"""Hydrate channels from a checkpoint.
|
||||
|
||||
@@ -487,12 +519,20 @@ def channels_from_checkpoint(
|
||||
histories = saver.get_delta_channel_history(
|
||||
config=config, channels=delta_channels
|
||||
)
|
||||
log_channels = _log_specs(channel_specs, checkpoint, load_logs)
|
||||
_require_saver_for_log(log_channels, saver, config)
|
||||
logs: dict[str, list[Any]] = {}
|
||||
if log_channels and saver is not None and config is not None:
|
||||
for k in log_channels:
|
||||
logs[k] = [e for _, e in saver.read_log(config, k).entries]
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, spec in channel_specs.items():
|
||||
ch: BaseChannel
|
||||
if k in histories:
|
||||
ch = _replay(spec, histories[k])
|
||||
elif k in logs:
|
||||
ch = cast(Log, spec).from_log(checkpoint["channel_values"][k], logs[k])
|
||||
else:
|
||||
ch = spec.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
channels[k] = ch
|
||||
@@ -505,6 +545,7 @@ async def achannels_from_checkpoint(
|
||||
*,
|
||||
saver: BaseCheckpointSaver | None = None,
|
||||
config: RunnableConfig | None = None,
|
||||
load_logs: bool = True,
|
||||
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
|
||||
"""Async version of `channels_from_checkpoint`. See docstring there."""
|
||||
channel_specs: dict[str, BaseChannel] = {}
|
||||
@@ -522,12 +563,20 @@ async def achannels_from_checkpoint(
|
||||
histories = await saver.aget_delta_channel_history(
|
||||
config=config, channels=delta_channels
|
||||
)
|
||||
log_channels = _log_specs(channel_specs, checkpoint, load_logs)
|
||||
_require_saver_for_log(log_channels, saver, config)
|
||||
logs: dict[str, list[Any]] = {}
|
||||
if log_channels and saver is not None and config is not None:
|
||||
for k in log_channels:
|
||||
logs[k] = [e for _, e in (await saver.aread_log(config, k)).entries]
|
||||
|
||||
channels: dict[str, BaseChannel] = {}
|
||||
for k, spec in channel_specs.items():
|
||||
ch: BaseChannel
|
||||
if k in histories:
|
||||
ch = _replay(spec, histories[k])
|
||||
elif k in logs:
|
||||
ch = cast(Log, spec).from_log(checkpoint["channel_values"][k], logs[k])
|
||||
else:
|
||||
ch = spec.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
|
||||
channels[k] = ch
|
||||
|
||||
@@ -232,8 +232,6 @@ def draw_graph(
|
||||
tools_by_name = getattr(node.bound, "tools_by_name", None)
|
||||
if isinstance(tools_by_name, Mapping) and tools_by_name:
|
||||
metadata["__tools"] = list(tools_by_name)
|
||||
if subagents := _get_tool_subagents(tools_by_name):
|
||||
metadata["__subagents"] = subagents
|
||||
graph.add_node(node.bound, name, metadata=metadata or None)
|
||||
# add start node
|
||||
if START not in nodes:
|
||||
@@ -283,38 +281,6 @@ def draw_graph(
|
||||
return graph
|
||||
|
||||
|
||||
def _get_tool_subagents(
|
||||
tools_by_name: Mapping[str, Any],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Collect the subagents each tool can run, keyed by tool name."""
|
||||
subagents: dict[str, list[dict[str, Any]]] = {}
|
||||
for tool_name, tool in tools_by_name.items():
|
||||
tool_subagents = getattr(tool, "subagents", None)
|
||||
if (
|
||||
isinstance(tool_subagents, Sequence)
|
||||
and not isinstance(tool_subagents, str)
|
||||
and tool_subagents
|
||||
and all(
|
||||
isinstance(subagent, Mapping) and isinstance(subagent.get("name"), str)
|
||||
for subagent in tool_subagents
|
||||
)
|
||||
):
|
||||
subagents[tool_name] = [
|
||||
_subagent_entry(subagent) for subagent in tool_subagents
|
||||
]
|
||||
return subagents
|
||||
|
||||
|
||||
def _subagent_entry(subagent: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Keep only the subagent fields graph viewers display."""
|
||||
entry: dict[str, Any] = {"name": subagent["name"]}
|
||||
if isinstance(description := subagent.get("description"), str):
|
||||
entry["description"] = description
|
||||
if subagent.get("forked") is True:
|
||||
entry["forked"] = True
|
||||
return entry
|
||||
|
||||
|
||||
def add_edge(
|
||||
graph: Graph,
|
||||
source: str,
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import binascii
|
||||
import concurrent.futures
|
||||
import json
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from contextlib import (
|
||||
@@ -12,6 +13,7 @@ from contextlib import (
|
||||
ExitStack,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from types import TracebackType
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -19,7 +21,7 @@ from typing import (
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
from uuid import UUID, uuid5
|
||||
from uuid import UUID, uuid4, uuid5
|
||||
|
||||
from langchain_core.callbacks import AsyncParentRunManager, ParentRunManager
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
@@ -33,6 +35,7 @@ from langgraph.checkpoint.base import (
|
||||
CheckpointTuple,
|
||||
PendingWrite,
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import LogAppend, LogStep
|
||||
from langgraph.store.base import BaseStore
|
||||
from typing_extensions import ParamSpec, Self
|
||||
|
||||
@@ -72,6 +75,7 @@ from langgraph.callbacks import (
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import _get_overwrite
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.log import Log
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import (
|
||||
@@ -356,6 +360,9 @@ class PregelLoop:
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.durability = durability
|
||||
# identifies this execution's log writes (transcript log prototype)
|
||||
self._log_exec = str(uuid4())
|
||||
self._log_inline_first_step = False
|
||||
self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
|
||||
self._graph_lifecycle_events = deque()
|
||||
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
|
||||
@@ -513,6 +520,7 @@ class PregelLoop:
|
||||
# save writes
|
||||
self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
|
||||
if self.durability != "exit" and self.checkpointer_put_writes is not None:
|
||||
writes_to_save = self._log_writes_for_saver(writes_to_save)
|
||||
config = patch_configurable(
|
||||
self.checkpoint_config,
|
||||
{
|
||||
@@ -522,13 +530,21 @@ class PregelLoop:
|
||||
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
|
||||
},
|
||||
)
|
||||
# A log write is placed relative to the log of the checkpoint it is
|
||||
# stored on, so that checkpoint must be saved first.
|
||||
put_writes_fn = self.checkpointer_put_writes
|
||||
if any(isinstance(self.specs.get(c), Log) for c, _ in writes_to_save):
|
||||
put_writes_fn = partial(
|
||||
self._put_writes_after_checkpoint,
|
||||
getattr(self, "_put_checkpoint_fut", None),
|
||||
)
|
||||
if self.checkpointer_put_writes_accepts_task_path:
|
||||
if hasattr(self, "tasks"):
|
||||
task = self.tasks.get(task_id)
|
||||
else:
|
||||
task = None
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
put_writes_fn,
|
||||
config,
|
||||
writes_to_save,
|
||||
task_id,
|
||||
@@ -536,13 +552,16 @@ class PregelLoop:
|
||||
)
|
||||
else:
|
||||
fut = self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
put_writes_fn,
|
||||
config,
|
||||
writes_to_save,
|
||||
task_id,
|
||||
)
|
||||
# the checkpoint that commits these writes must not be saved before
|
||||
# them: a delta replay or a log step reads them
|
||||
if self._delta_write_futs is not None and any(
|
||||
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
|
||||
isinstance(self.specs.get(c), (DeltaChannel, Log))
|
||||
for c, _ in writes_to_save
|
||||
):
|
||||
self._delta_write_futs.append(fut)
|
||||
# ERROR_SOURCE_NODE is only appended by commit() when the task
|
||||
@@ -556,6 +575,24 @@ class PregelLoop:
|
||||
if hasattr(self, "tasks"):
|
||||
self.output_writes(task_id, writes, cached=cached)
|
||||
|
||||
def _log_write_from_saver(self, channel: str, value: Any) -> Any:
|
||||
"""A saved `LogAppend` as the write it was: a list of entries."""
|
||||
spec = self.specs.get(channel)
|
||||
if isinstance(value, LogAppend) and isinstance(spec, Log):
|
||||
return [spec.decode(json.loads(t)) for t in value.entries]
|
||||
return value
|
||||
|
||||
def _log_writes_for_saver(self, writes: WritesT) -> WritesT:
|
||||
"""Writes to log channels as the `LogAppend`s handed to the saver."""
|
||||
if not any(isinstance(self.channels.get(c), Log) for c, _ in writes):
|
||||
return writes
|
||||
return [
|
||||
(c, ch.append_value(v, self._log_exec))
|
||||
if isinstance(ch := self.channels.get(c), Log)
|
||||
else (c, v)
|
||||
for c, v in writes
|
||||
]
|
||||
|
||||
def _put_pending_writes(self) -> None:
|
||||
if self.checkpointer_put_writes is None:
|
||||
return
|
||||
@@ -579,6 +616,7 @@ class PregelLoop:
|
||||
by_task[task_id].append((channel, value))
|
||||
# submit writes to checkpointer
|
||||
for task_id, writes in by_task.items():
|
||||
writes = self._log_writes_for_saver(writes)
|
||||
if self.checkpointer_put_writes_accepts_task_path and hasattr(
|
||||
self, "tasks"
|
||||
):
|
||||
@@ -1311,6 +1349,8 @@ class PregelLoop:
|
||||
stored_versions=self.checkpoint_previous_versions,
|
||||
trigger_to_nodes=self.trigger_to_nodes,
|
||||
)
|
||||
if do_checkpoint:
|
||||
self._finish_log_steps(metadata)
|
||||
for k in channels_to_snapshot:
|
||||
new_counters[k] = (0, 0)
|
||||
if do_checkpoint:
|
||||
@@ -1383,6 +1423,51 @@ class PregelLoop:
|
||||
# increment step
|
||||
self.step += 1
|
||||
|
||||
def _finish_log_steps(self, metadata: CheckpointMetadata) -> None:
|
||||
"""Stamp the log steps of the checkpoint about to be saved.
|
||||
|
||||
A step's entries reached the saver through `put_writes` when the
|
||||
checkpoint comes from the loop and writes are saved as they happen;
|
||||
then the step carries no entries. Otherwise (graph input, a fork,
|
||||
`durability="exit"`) it carries them inline.
|
||||
|
||||
A resume at a checkpoint the thread moved past re-runs finished tasks
|
||||
on that same checkpoint, and a saver without native log support keeps
|
||||
the first run's writes under the same keys. So the first step of such
|
||||
a resume carries its entries inline too.
|
||||
|
||||
So does a step that commits a seed (the list a field stored before it
|
||||
became a log channel), which never went through `put_writes`. The
|
||||
channel's version is bumped if the step didn't change it, so that the
|
||||
saver stores the step and the checkpoint points at the log.
|
||||
"""
|
||||
inline = (
|
||||
metadata.get("source") != "loop"
|
||||
or self.durability == "exit"
|
||||
or self._log_inline_first_step
|
||||
)
|
||||
if metadata.get("source") == "loop":
|
||||
self._log_inline_first_step = False
|
||||
values = self.checkpoint["channel_values"]
|
||||
for k, ch in self.channels.items():
|
||||
if not isinstance(ch, Log):
|
||||
continue
|
||||
step = values.get(k)
|
||||
seeding, ch.seeding = ch.seeding, False
|
||||
if seeding:
|
||||
versions = self.checkpoint["channel_versions"]
|
||||
if versions.get(k) == self.checkpoint_previous_versions.get(k):
|
||||
versions[k] = self.checkpointer_get_next_version(
|
||||
versions.get(k), None
|
||||
)
|
||||
if isinstance(step, LogStep) and step.entries is not None:
|
||||
values[k] = LogStep(
|
||||
step.start,
|
||||
step.count,
|
||||
self._log_exec,
|
||||
step.entries if inline or seeding else None,
|
||||
)
|
||||
|
||||
def _put_exit_delta_writes(self) -> None:
|
||||
"""Stage stub + accumulated delta writes so final_checkpoint's put
|
||||
waits on them (visibility invariant: both must be durable before
|
||||
@@ -1709,6 +1794,13 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
self.checkpointer_put_writes = None
|
||||
self.checkpointer_put_writes_accepts_task_path = False
|
||||
|
||||
def _put_writes_after_checkpoint(
|
||||
self, prev: concurrent.futures.Future | None, *args: Any
|
||||
) -> None:
|
||||
if prev is not None:
|
||||
prev.result()
|
||||
cast(Callable[..., None], self.checkpointer_put_writes)(*args)
|
||||
|
||||
def _checkpointer_put_after_previous(
|
||||
self,
|
||||
prev: concurrent.futures.Future | None,
|
||||
@@ -1821,6 +1913,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
self._addressed_checkpoint_superseded = checkpoint_superseded(
|
||||
self.checkpointer, self.checkpoint_config, saved
|
||||
)
|
||||
self._log_inline_first_step = (
|
||||
self._addressed_checkpoint_superseded
|
||||
and not self.checkpointer.supports_log
|
||||
)
|
||||
elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
|
||||
# Subgraph replay: the parent is replaying and passed us a
|
||||
# replay_state with its checkpoint_id. Look up our checkpoint
|
||||
@@ -1865,7 +1961,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
self.checkpoint = saved.checkpoint
|
||||
self.checkpoint_metadata = saved.metadata
|
||||
self.checkpoint_pending_writes = (
|
||||
[(str(tid), k, v) for tid, k, v in saved.pending_writes]
|
||||
[
|
||||
(str(tid), k, self._log_write_from_saver(k, v))
|
||||
for tid, k, v in saved.pending_writes
|
||||
]
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
@@ -1965,6 +2064,13 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
self.checkpointer_put_writes = None
|
||||
self.checkpointer_put_writes_accepts_task_path = False
|
||||
|
||||
async def _put_writes_after_checkpoint(
|
||||
self, prev: asyncio.Task | None, *args: Any
|
||||
) -> None:
|
||||
if prev is not None:
|
||||
await prev
|
||||
await cast(Callable[..., Any], self.checkpointer_put_writes)(*args)
|
||||
|
||||
async def _checkpointer_put_after_previous(
|
||||
self,
|
||||
prev: asyncio.Task | None,
|
||||
@@ -2077,6 +2183,10 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
self._addressed_checkpoint_superseded = await acheckpoint_superseded(
|
||||
self.checkpointer, self.checkpoint_config, saved
|
||||
)
|
||||
self._log_inline_first_step = (
|
||||
self._addressed_checkpoint_superseded
|
||||
and not self.checkpointer.supports_log
|
||||
)
|
||||
elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
|
||||
# Subgraph replay: the parent is replaying and passed us a
|
||||
# replay_state with its checkpoint_id. Look up our checkpoint
|
||||
@@ -2121,7 +2231,10 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
self.checkpoint = saved.checkpoint
|
||||
self.checkpoint_metadata = saved.metadata
|
||||
self.checkpoint_pending_writes = (
|
||||
[(str(tid), k, v) for tid, k, v in saved.pending_writes]
|
||||
[
|
||||
(str(tid), k, self._log_write_from_saver(k, v))
|
||||
for tid, k, v in saved.pending_writes
|
||||
]
|
||||
if saved.pending_writes is not None
|
||||
else []
|
||||
)
|
||||
|
||||
@@ -23,9 +23,10 @@ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResul
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph._internal._config import filter_to_user_tags
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph._internal._constants import NS_SEP, PUSH
|
||||
from langgraph.channels.binop import _get_overwrite
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.log import MessagesLog
|
||||
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
|
||||
from langgraph.pregel.protocol import StreamChunk
|
||||
from langgraph.types import Command, Overwrite
|
||||
@@ -55,6 +56,23 @@ def _state_values(obj: Any) -> Sequence[Any]:
|
||||
return ()
|
||||
|
||||
|
||||
def graph_namespace(metadata: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
"""Namespace of the graph a task runs in, from the task's run metadata.
|
||||
|
||||
Drops the task's own segment, plus one per enclosing `@task` call: a call
|
||||
runs in its caller's graph but nests its segment under the caller's. A
|
||||
call's `langgraph_path` is `(PUSH, <caller's path>, <index>, True)`, while
|
||||
a `Send` task's ends in `False`.
|
||||
"""
|
||||
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))
|
||||
path = metadata.get("langgraph_path", ())
|
||||
drop = 1
|
||||
while len(path) == 4 and path[0] == PUSH and path[-1] is True:
|
||||
drop += 1
|
||||
path = path[1]
|
||||
return ns[:-drop]
|
||||
|
||||
|
||||
class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
"""A callback handler that implements stream_mode=messages.
|
||||
|
||||
@@ -148,9 +166,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
if metadata and (not tags or (TAG_NOSTREAM not in tags)):
|
||||
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
|
||||
:-1
|
||||
]
|
||||
ns = graph_namespace(metadata)
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return
|
||||
if (filtered_tags := filter_to_user_tags(tags)) is not None:
|
||||
@@ -213,10 +229,8 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
and kwargs.get("name") == metadata.get("langgraph_node")
|
||||
and (not tags or TAG_HIDDEN not in tags)
|
||||
):
|
||||
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
|
||||
:-1
|
||||
]
|
||||
if not self.subgraphs and len(ns) > 0:
|
||||
ns = graph_namespace(metadata)
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return
|
||||
self.metadata[run_id] = (ns, metadata)
|
||||
for value in _state_values(inputs):
|
||||
@@ -524,6 +538,13 @@ def ensure_message_ids(
|
||||
"""
|
||||
for i, (ch, value) in enumerate(writes):
|
||||
spec = channels.get(ch)
|
||||
if isinstance(spec, MessagesLog):
|
||||
# ids must be set before the write is saved, so the saved
|
||||
# entries and the run's in-memory messages agree
|
||||
new_value = _with_message_ids(value)
|
||||
if new_value is not value:
|
||||
cast(MutableSequence[tuple[str, Any]], writes)[i] = (ch, new_value)
|
||||
continue
|
||||
if not isinstance(spec, DeltaChannel):
|
||||
continue
|
||||
if spec.reducer is _messages_delta_reducer:
|
||||
|
||||
@@ -227,23 +227,14 @@ class PregelNode:
|
||||
def node(self) -> Runnable[Any, Any] | None:
|
||||
"""Get a runnable that combines `bound` and `writers`."""
|
||||
writers = self.flat_writers
|
||||
trace_inputs = self.trace_policy.process_inputs if self.trace_policy else None
|
||||
trace_outputs = self.trace_policy.process_outputs if self.trace_policy else None
|
||||
if self.bound is DEFAULT_BOUND and not writers:
|
||||
return None
|
||||
elif self.bound is DEFAULT_BOUND and len(writers) == 1:
|
||||
return writers[0]
|
||||
elif self.bound is DEFAULT_BOUND:
|
||||
return RunnableSeq(
|
||||
*writers, trace_inputs=trace_inputs, trace_outputs=trace_outputs
|
||||
)
|
||||
return RunnableSeq(*writers, trace_policy=self.trace_policy)
|
||||
elif writers:
|
||||
return RunnableSeq(
|
||||
self.bound,
|
||||
*writers,
|
||||
trace_inputs=trace_inputs,
|
||||
trace_outputs=trace_outputs,
|
||||
)
|
||||
return RunnableSeq(self.bound, *writers, trace_policy=self.trace_policy)
|
||||
else:
|
||||
return self.bound
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ from uuid import UUID
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph.constants import TAG_NOSTREAM
|
||||
from langgraph.errors import GraphDrained, GraphInterrupt
|
||||
from langgraph.pregel._messages import graph_namespace
|
||||
from langgraph.pregel.protocol import StreamChunk
|
||||
|
||||
try:
|
||||
@@ -92,12 +92,9 @@ class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
) -> tuple[str, ...] | None:
|
||||
"""Resolve the namespace this tool call should emit at, or `None` to skip.
|
||||
|
||||
Mirrors `StreamMessagesHandler.on_chat_model_start`'s namespace
|
||||
derivation: parses `langgraph_checkpoint_ns` (which ends with
|
||||
the `node_name:task_id` of the calling node), drops that
|
||||
trailing segment, and returns the containing subgraph's own
|
||||
namespace. Returns `None` when the call should be silently
|
||||
suppressed:
|
||||
Mirrors `StreamMessagesHandler.on_chat_model_start`: the namespace
|
||||
is that of the graph the tool runs in (`graph_namespace`). Returns
|
||||
`None` when the call should be silently suppressed:
|
||||
|
||||
- `metadata` is missing — handler is attached to a context
|
||||
without Pregel routing info.
|
||||
@@ -110,11 +107,9 @@ class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
return None
|
||||
if tags and TAG_NOSTREAM in tags:
|
||||
return None
|
||||
nskey = metadata.get("langgraph_checkpoint_ns")
|
||||
if not nskey:
|
||||
ns: tuple[str, ...] = ()
|
||||
else:
|
||||
ns = tuple(cast(str, nskey).split(NS_SEP))[:-1]
|
||||
ns = (
|
||||
graph_namespace(metadata) if metadata.get("langgraph_checkpoint_ns") else ()
|
||||
)
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return None
|
||||
return ns
|
||||
|
||||
@@ -49,6 +49,8 @@ from langgraph.checkpoint.base import (
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointTuple,
|
||||
LogPage,
|
||||
log_range,
|
||||
)
|
||||
from langgraph.store.base import BaseStore
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
@@ -97,6 +99,7 @@ from langgraph._internal._runnable import (
|
||||
Runnable,
|
||||
RunnableLike,
|
||||
RunnableSeq,
|
||||
_is_stream_handler,
|
||||
coerce_to_runnable,
|
||||
)
|
||||
from langgraph._internal._timeout import coerce_timeout_policy
|
||||
@@ -109,6 +112,7 @@ from langgraph.callbacks import (
|
||||
)
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.channels.log import Log
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.config import get_config
|
||||
from langgraph.constants import END
|
||||
@@ -203,11 +207,6 @@ from langgraph.types import (
|
||||
from langgraph.typing import ContextT, InputT, OutputT, StateT
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV10
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = None # type: ignore
|
||||
|
||||
__all__ = ("NodeBuilder", "Pregel")
|
||||
|
||||
_WriteValue = Callable[[Input], Output] | Any
|
||||
@@ -1249,6 +1248,7 @@ class Pregel(
|
||||
saved.checkpoint,
|
||||
saver=saver,
|
||||
config=saved.config,
|
||||
load_logs=False,
|
||||
)
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
@@ -1374,6 +1374,7 @@ class Pregel(
|
||||
saved.checkpoint,
|
||||
saver=saver,
|
||||
config=saved.config,
|
||||
load_logs=False,
|
||||
)
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
@@ -1486,6 +1487,97 @@ class Pregel(
|
||||
live=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
|
||||
)
|
||||
|
||||
def read_log(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
channel: str,
|
||||
*,
|
||||
after: int | None = None,
|
||||
before: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> LogPage:
|
||||
"""Read a log channel as of a checkpoint (prototype).
|
||||
|
||||
The same operation as `BaseCheckpointSaver.read_log`, with entries
|
||||
decoded by the channel (messages for a `MessagesLog`).
|
||||
"""
|
||||
checkpointer = self._state_checkpointer(config)
|
||||
if subgraph := self._subgraph_for_namespace(config, checkpointer):
|
||||
pregel, subgraph_config = subgraph
|
||||
return pregel.read_log( # type: ignore[attr-defined]
|
||||
subgraph_config, channel, after=after, before=before, limit=limit
|
||||
)
|
||||
config = merge_configs(self.config, config) if self.config else config
|
||||
config = self._own_checkpoint_config(config)
|
||||
page = checkpointer.read_log(
|
||||
config, channel, after=after, before=before, limit=limit
|
||||
)
|
||||
if page.length == 0 and (tup := checkpointer.get_tuple(config)) is not None:
|
||||
if (
|
||||
pre := self._pre_log_page(channel, tup, after, before, limit)
|
||||
) is not None:
|
||||
return pre
|
||||
return self._decode_log_page(channel, page)
|
||||
|
||||
async def aread_log(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
channel: str,
|
||||
*,
|
||||
after: int | None = None,
|
||||
before: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> LogPage:
|
||||
"""Async version of `read_log`."""
|
||||
checkpointer = self._state_checkpointer(config)
|
||||
if subgraph := self._subgraph_for_namespace(config, checkpointer):
|
||||
pregel, subgraph_config = subgraph
|
||||
return await pregel.aread_log( # type: ignore[attr-defined]
|
||||
subgraph_config, channel, after=after, before=before, limit=limit
|
||||
)
|
||||
config = merge_configs(self.config, config) if self.config else config
|
||||
config = self._own_checkpoint_config(config)
|
||||
page = await checkpointer.aread_log(
|
||||
config, channel, after=after, before=before, limit=limit
|
||||
)
|
||||
if (
|
||||
page.length == 0
|
||||
and (tup := await checkpointer.aget_tuple(config)) is not None
|
||||
):
|
||||
if (
|
||||
pre := self._pre_log_page(channel, tup, after, before, limit)
|
||||
) is not None:
|
||||
return pre
|
||||
return self._decode_log_page(channel, page)
|
||||
|
||||
def _pre_log_page(
|
||||
self,
|
||||
channel: str,
|
||||
tup: CheckpointTuple,
|
||||
after: int | None,
|
||||
before: int | None,
|
||||
limit: int | None,
|
||||
) -> LogPage | None:
|
||||
"""A page of the list a checkpoint stored before the field became a
|
||||
log channel, or `None` if it stored none."""
|
||||
spec = self.channels.get(channel)
|
||||
stored = tup.checkpoint["channel_values"].get(channel)
|
||||
if not isinstance(spec, Log) or not isinstance(stored, list):
|
||||
return None
|
||||
entries = spec.from_checkpoint(stored).entries
|
||||
lo, hi = log_range(len(entries), after, before, limit)
|
||||
return LogPage([(i, entries[i]) for i in range(lo, hi)], len(entries), [])
|
||||
|
||||
def _decode_log_page(self, channel: str, page: LogPage) -> LogPage:
|
||||
spec = self.channels.get(channel)
|
||||
if not isinstance(spec, Log):
|
||||
raise ValueError(f"Channel {channel!r} is not a log channel")
|
||||
return LogPage(
|
||||
[(pos, spec.decode(e)) for pos, e in page.entries],
|
||||
page.length,
|
||||
[spec.decode(e) for e in page.pending],
|
||||
)
|
||||
|
||||
async def aget_state(
|
||||
self, config: RunnableConfig, *, subgraphs: bool = False
|
||||
) -> StateSnapshot:
|
||||
@@ -2880,6 +2972,20 @@ class Pregel(
|
||||
for h in callback_manager.inheritable_handlers
|
||||
if not isinstance(h, StreamMessagesHandlerV2)
|
||||
]
|
||||
if CONFIG_KEY_TASK_ID not in config[CONF]:
|
||||
# Not run as a task (a child invoked with its own thread, say),
|
||||
# this graph's namespace starts at the root, so a caller's
|
||||
# handler would take this graph's events for its own graph's.
|
||||
callback_manager.handlers = [
|
||||
h
|
||||
for h in callback_manager.handlers
|
||||
if not _is_stream_mode_handler(h)
|
||||
]
|
||||
callback_manager.inheritable_handlers = [
|
||||
h
|
||||
for h in callback_manager.inheritable_handlers
|
||||
if not _is_stream_mode_handler(h)
|
||||
]
|
||||
if "ls_integration" not in callback_manager.metadata:
|
||||
callback_manager.add_metadata({"ls_integration": "langgraph"})
|
||||
run_manager = callback_manager.on_chain_start(
|
||||
@@ -2898,9 +3004,9 @@ class Pregel(
|
||||
)
|
||||
# set up subgraph checkpointing
|
||||
config = self._own_checkpoint_config(config)
|
||||
stream_handlers: list[StreamMessagesHandler | StreamToolCallHandler] = []
|
||||
# set up messages stream mode
|
||||
if "messages" in stream_modes:
|
||||
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
use_stream_messages_v2 = bool(
|
||||
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
||||
)
|
||||
@@ -2909,24 +3015,12 @@ class Pregel(
|
||||
if use_stream_messages_v2
|
||||
else StreamMessagesHandler
|
||||
)
|
||||
run_manager.inheritable_handlers.append(
|
||||
messages_handler_cls(
|
||||
stream.put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
)
|
||||
)
|
||||
stream_handlers.append(messages_handler_cls(stream.put, subgraphs))
|
||||
|
||||
# set up tools stream mode
|
||||
if "tools" in stream_modes:
|
||||
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamToolCallHandler(
|
||||
stream.put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
|
||||
)
|
||||
)
|
||||
stream_handlers.append(StreamToolCallHandler(stream.put, subgraphs))
|
||||
run_manager.inheritable_handlers.extend(stream_handlers)
|
||||
|
||||
# set up custom stream mode
|
||||
if "custom" in stream_modes:
|
||||
@@ -3009,6 +3103,10 @@ class Pregel(
|
||||
cache_policy=self.cache_policy,
|
||||
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
|
||||
) as loop:
|
||||
# Known only now: the loop numbers the namespace of every
|
||||
# subgraph a task runs after its first.
|
||||
for handler in stream_handlers:
|
||||
handler.parent_ns = loop.checkpoint_ns
|
||||
emit_graph_lifecycle_events(loop)
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
@@ -3291,6 +3389,20 @@ class Pregel(
|
||||
for h in callback_manager.inheritable_handlers
|
||||
if not isinstance(h, StreamMessagesHandlerV2)
|
||||
]
|
||||
if CONFIG_KEY_TASK_ID not in config[CONF]:
|
||||
# Not run as a task (a child invoked with its own thread, say),
|
||||
# this graph's namespace starts at the root, so a caller's
|
||||
# handler would take this graph's events for its own graph's.
|
||||
callback_manager.handlers = [
|
||||
h
|
||||
for h in callback_manager.handlers
|
||||
if not _is_stream_mode_handler(h)
|
||||
]
|
||||
callback_manager.inheritable_handlers = [
|
||||
h
|
||||
for h in callback_manager.inheritable_handlers
|
||||
if not _is_stream_mode_handler(h)
|
||||
]
|
||||
if "ls_integration" not in callback_manager.metadata:
|
||||
callback_manager.add_metadata({"ls_integration": "langgraph"})
|
||||
run_manager = await callback_manager.on_chain_start(
|
||||
@@ -3304,20 +3416,10 @@ class Pregel(
|
||||
run_id=run_manager.run_id,
|
||||
)
|
||||
# if running from astream_log() run each proc with streaming
|
||||
do_stream = (
|
||||
next(
|
||||
(
|
||||
True
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
and not isinstance(
|
||||
h, (StreamMessagesHandler, StreamToolCallHandler)
|
||||
)
|
||||
),
|
||||
False,
|
||||
)
|
||||
if _StreamingCallbackHandler is not None
|
||||
else False
|
||||
do_stream = any(
|
||||
_is_stream_handler(h)
|
||||
and not isinstance(h, (StreamMessagesHandler, StreamToolCallHandler))
|
||||
for h in run_manager.handlers
|
||||
)
|
||||
if checkpointer is None and durability is not None:
|
||||
warnings.warn(
|
||||
@@ -3325,10 +3427,9 @@ class Pregel(
|
||||
)
|
||||
# set up subgraph checkpointing
|
||||
config = self._own_checkpoint_config(config)
|
||||
stream_handlers: list[StreamMessagesHandler | StreamToolCallHandler] = []
|
||||
# set up messages stream mode
|
||||
if "messages" in stream_modes:
|
||||
# namespace can be None in a root level graph?
|
||||
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
use_stream_messages_v2 = bool(
|
||||
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
||||
)
|
||||
@@ -3337,24 +3438,12 @@ class Pregel(
|
||||
if use_stream_messages_v2
|
||||
else StreamMessagesHandler
|
||||
)
|
||||
run_manager.inheritable_handlers.append(
|
||||
messages_handler_cls(
|
||||
stream_put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
||||
)
|
||||
)
|
||||
stream_handlers.append(messages_handler_cls(stream_put, subgraphs))
|
||||
|
||||
# set up tools stream mode
|
||||
if "tools" in stream_modes:
|
||||
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamToolCallHandler(
|
||||
stream_put,
|
||||
subgraphs,
|
||||
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
|
||||
)
|
||||
)
|
||||
stream_handlers.append(StreamToolCallHandler(stream_put, subgraphs))
|
||||
run_manager.inheritable_handlers.extend(stream_handlers)
|
||||
|
||||
# set up custom stream mode
|
||||
def stream_writer(c: Any) -> None:
|
||||
@@ -3463,6 +3552,10 @@ class Pregel(
|
||||
cache_policy=self.cache_policy,
|
||||
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
|
||||
) as loop:
|
||||
# Known only now: the loop numbers the namespace of every
|
||||
# subgraph a task runs after its first.
|
||||
for handler in stream_handlers:
|
||||
handler.parent_ns = loop.checkpoint_ns
|
||||
await aemit_graph_lifecycle_events(loop)
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
@@ -4460,6 +4553,10 @@ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> Non
|
||||
payload["values"] = mapper(payload["values"])
|
||||
|
||||
|
||||
def _is_stream_mode_handler(handler: object) -> bool:
|
||||
return isinstance(handler, (StreamMessagesHandler, StreamToolCallHandler))
|
||||
|
||||
|
||||
def _resolve_parent_ns(
|
||||
graph_config: RunnableConfig | None, call_config: RunnableConfig | None
|
||||
) -> tuple[str, ...]:
|
||||
|
||||
@@ -847,6 +847,11 @@ class RemoteGraph(PregelProtocol):
|
||||
# filter for what was actually requested
|
||||
if mode not in requested:
|
||||
continue
|
||||
# subgraph events may have been requested only for the parent stream.
|
||||
# messages are exempt: the server dedupes them across namespaces, so
|
||||
# dropping them here would also drop the root-level message
|
||||
if not subgraphs and NS_SEP in chunk.event and mode != "messages":
|
||||
continue
|
||||
|
||||
if chunk.event.startswith("messages"):
|
||||
chunk = chunk._replace(data=tuple(chunk.data))
|
||||
@@ -997,6 +1002,11 @@ class RemoteGraph(PregelProtocol):
|
||||
# filter for what was actually requested
|
||||
if mode not in requested:
|
||||
continue
|
||||
# subgraph events may have been requested only for the parent stream.
|
||||
# messages are exempt: the server dedupes them across namespaces, so
|
||||
# dropping them here would also drop the root-level message
|
||||
if not subgraphs and NS_SEP in chunk.event and mode != "messages":
|
||||
continue
|
||||
|
||||
if chunk.event.startswith("messages"):
|
||||
chunk = chunk._replace(data=tuple(chunk.data))
|
||||
|
||||
@@ -7,6 +7,7 @@ __all__ = (
|
||||
"LangGraphDeprecatedSinceV05",
|
||||
"LangGraphDeprecatedSinceV10",
|
||||
"LangGraphDeprecatedSinceV11",
|
||||
"LangGraphDeprecatedSinceV13",
|
||||
)
|
||||
|
||||
|
||||
@@ -67,3 +68,10 @@ class LangGraphDeprecatedSinceV11(LangGraphDeprecationWarning):
|
||||
|
||||
def __init__(self, message: str, *args: object) -> None:
|
||||
super().__init__(message, *args, since=(1, 1), expected_removal=(3, 0))
|
||||
|
||||
|
||||
class LangGraphDeprecatedSinceV13(LangGraphDeprecationWarning):
|
||||
"""A specific `LangGraphDeprecationWarning` subclass defining functionality deprecated since LangGraph v1.3.0"""
|
||||
|
||||
def __init__(self, message: str, *args: object) -> None:
|
||||
super().__init__(message, *args, since=(1, 3), expected_removal=(2, 0))
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.2.18"
|
||||
version = "1.2.19"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Transcript log prototype: append-only `Log` / `MessagesLog` channels.
|
||||
|
||||
Every scenario runs against `InMemorySaver` (native log storage: segments,
|
||||
step rows, range reads) and `SqliteSaver` (no native support: the default
|
||||
`read_log` walks the parent chain), and both must read the same log.
|
||||
"""
|
||||
|
||||
import operator
|
||||
import sqlite3
|
||||
import warnings
|
||||
from collections.abc import Iterator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, LogPage
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
from langgraph.prebuilt import ToolNode, tools_condition
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.channels import Log, LogRef, MessagesLog
|
||||
from langgraph.errors import InvalidUpdateError
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
|
||||
from langgraph.types import Command, Overwrite, Send, interrupt
|
||||
from langgraph.warnings import LangGraphDeprecatedSinceV13
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "sqlite"])
|
||||
def saver(request: pytest.FixtureRequest) -> Iterator[BaseCheckpointSaver]:
|
||||
if request.param == "memory":
|
||||
yield InMemorySaver()
|
||||
else:
|
||||
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
yield SqliteSaver(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
def contents(page: LogPage) -> list[Any]:
|
||||
return [e.content if hasattr(e, "content") else e for _, e in page.entries]
|
||||
|
||||
|
||||
class ChatState(TypedDict):
|
||||
messages: Annotated[list, MessagesLog()]
|
||||
|
||||
|
||||
def chat_graph(saver: BaseCheckpointSaver, **compile_kw: Any):
|
||||
def model(state: ChatState) -> dict:
|
||||
return {"messages": [AIMessage(f"reply to {state['messages'][-1].content}")]}
|
||||
|
||||
builder = StateGraph(ChatState)
|
||||
builder.add_node("model", model)
|
||||
builder.add_edge(START, "model")
|
||||
builder.add_edge("model", END)
|
||||
return builder.compile(checkpointer=saver, **compile_kw)
|
||||
|
||||
|
||||
def test_appends_reads_and_state_marker(saver: BaseCheckpointSaver) -> None:
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
out = graph.invoke({"messages": [HumanMessage("b")]}, config)
|
||||
|
||||
# nodes and invoke() see the list
|
||||
assert [m.content for m in out["messages"]] == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
# state reads return the marker, not the transcript
|
||||
assert graph.get_state(config).values == {"messages": LogRef(4)}
|
||||
# one read operation, same arguments everywhere
|
||||
page = graph.read_log(config, "messages")
|
||||
assert page.length == 4
|
||||
assert contents(page) == ["a", "reply to a", "b", "reply to b"]
|
||||
assert [p for p, _ in page.entries] == [0, 1, 2, 3]
|
||||
assert contents(graph.read_log(config, "messages", limit=2)) == ["b", "reply to b"]
|
||||
assert contents(graph.read_log(config, "messages", before=2, limit=1)) == [
|
||||
"reply to a"
|
||||
]
|
||||
assert contents(graph.read_log(config, "messages", after=1, limit=1)) == ["b"]
|
||||
# the saver-level read returns JSON
|
||||
raw = saver.read_log(config, "messages")
|
||||
assert raw.entries[0][1]["type"] == "human"
|
||||
# every checkpoint points at its own leaf
|
||||
lengths = [
|
||||
(s.metadata["source"], graph.read_log(s.config, "messages").length)
|
||||
for s in graph.get_state_history(config)
|
||||
]
|
||||
assert lengths == [
|
||||
("loop", 4),
|
||||
("loop", 3),
|
||||
("input", 2),
|
||||
("loop", 2),
|
||||
("loop", 1),
|
||||
("input", 0),
|
||||
]
|
||||
|
||||
|
||||
async def test_async_appends_and_reads() -> None:
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as fallback:
|
||||
for saver in (InMemorySaver(), fallback):
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
await graph.ainvoke({"messages": [HumanMessage("a")]}, config)
|
||||
await graph.ainvoke({"messages": [HumanMessage("b")]}, config)
|
||||
state = await graph.aget_state(config)
|
||||
assert state.values == {"messages": LogRef(4)}
|
||||
page = await graph.aread_log(config, "messages")
|
||||
assert contents(page) == ["a", "reply to a", "b", "reply to b"]
|
||||
|
||||
|
||||
def test_log_entries_are_not_stored_in_checkpoints() -> None:
|
||||
saver = InMemorySaver()
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
for i in range(20):
|
||||
graph.invoke({"messages": [HumanMessage(f"m{i}")]}, config)
|
||||
assert not [k for k in saver.blobs if k[2] == "messages"]
|
||||
log = saver.log.logs[("1", "", "messages")]
|
||||
stored = sum(len(rows) for rows in log.entries.values())
|
||||
# each entry stored once: linear in the transcript
|
||||
assert stored == 40 == graph.read_log(config, "messages").length
|
||||
assert len(log.segments) == 1
|
||||
|
||||
|
||||
def test_parallel_tasks_keep_one_order(saver: BaseCheckpointSaver) -> None:
|
||||
class State(TypedDict):
|
||||
events: Annotated[list, Log()]
|
||||
seen: Annotated[list, operator.add]
|
||||
|
||||
def fan_out(state: State) -> list[Send]:
|
||||
return [Send("worker", {"i": i}) for i in range(5)]
|
||||
|
||||
def worker(arg: dict) -> dict:
|
||||
return {"events": [{"worker": arg["i"]}, {"worker": arg["i"], "n": 2}]}
|
||||
|
||||
def after(state: State) -> dict:
|
||||
return {"seen": [list(state["events"])]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("worker", worker)
|
||||
builder.add_node("after", after)
|
||||
builder.add_conditional_edges(START, fan_out, ["worker"])
|
||||
builder.add_edge("worker", "after")
|
||||
builder.add_edge("after", END)
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
out = graph.invoke({"events": [], "seen": []}, config)
|
||||
in_run = out["seen"][-1]
|
||||
assert len(in_run) == 10
|
||||
# read back after the run: the same order the run's nodes saw
|
||||
assert contents(graph.read_log(config, "events")) == in_run
|
||||
|
||||
|
||||
def test_interrupt_and_resume_reuse_finished_writes(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class State(TypedDict):
|
||||
events: Annotated[list, Log()]
|
||||
|
||||
def fast(state: State) -> dict:
|
||||
calls.append("fast")
|
||||
return {"events": [{"from": "fast"}]}
|
||||
|
||||
def asks(state: State) -> dict:
|
||||
answer = interrupt("question")
|
||||
calls.append("asks")
|
||||
return {"events": [{"from": "asks", "answer": answer}]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("fast", fast)
|
||||
builder.add_node("asks", asks)
|
||||
builder.add_edge(START, "fast")
|
||||
builder.add_edge(START, "asks")
|
||||
builder.add_edge(["fast", "asks"], END)
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"events": [{"from": "input"}]}, config)
|
||||
# fast finished; its entry is pending at the head, not committed
|
||||
page = saver.read_log(config, "events")
|
||||
assert page.length == 1
|
||||
assert page.pending == [{"from": "fast"}]
|
||||
|
||||
out = graph.invoke(Command(resume="yes"), config)
|
||||
assert calls == ["fast", "asks"] # fast was not re-run
|
||||
# the reused write and the new one commit as one step, in path order,
|
||||
# and the log reads back exactly what the run's state held
|
||||
assert out["events"] == [
|
||||
{"from": "input"},
|
||||
{"from": "asks", "answer": "yes"},
|
||||
{"from": "fast"},
|
||||
]
|
||||
assert contents(graph.read_log(config, "events")) == out["events"]
|
||||
assert graph.read_log(config, "events").pending == []
|
||||
|
||||
|
||||
def test_time_travel_forks_without_touching_the_original(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
graph.invoke({"messages": [HumanMessage("b")]}, config)
|
||||
original = graph.get_state(config).config
|
||||
# the checkpoint before the second reply ("b" added, model not run)
|
||||
before_reply = next(
|
||||
s
|
||||
for s in graph.get_state_history(config)
|
||||
if s.next == ("model",) and graph.read_log(s.config, "messages").length == 3
|
||||
)
|
||||
graph.invoke(None, before_reply.config) # replay: a new branch
|
||||
head = graph.get_state(config).config
|
||||
assert (
|
||||
head["configurable"]["checkpoint_id"]
|
||||
!= original["configurable"]["checkpoint_id"]
|
||||
)
|
||||
assert contents(graph.read_log(head, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
assert contents(graph.read_log(original, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
a = graph.read_log(head, "messages").entries
|
||||
b = graph.read_log(original, "messages").entries
|
||||
# same prefix, different last message (a new run of the model node)
|
||||
assert [m.id for _, m in a[:3]] == [m.id for _, m in b[:3]]
|
||||
assert a[3][1].id != b[3][1].id
|
||||
if isinstance(saver, InMemorySaver):
|
||||
assert len(saver.log.logs[("1", "", "messages")].segments) == 2
|
||||
|
||||
|
||||
def test_update_state_fork_starts_a_branch(saver: BaseCheckpointSaver) -> None:
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
graph.invoke({"messages": [HumanMessage("b")]}, config)
|
||||
original = graph.get_state(config).config
|
||||
first_turn = next(
|
||||
s
|
||||
for s in graph.get_state_history(config)
|
||||
if graph.read_log(s.config, "messages").length == 2
|
||||
and s.metadata["source"] == "loop"
|
||||
)
|
||||
# edit: fork from the end of the first turn with a different question
|
||||
forked = graph.update_state(
|
||||
first_turn.config, {"messages": [HumanMessage("c")]}, as_node="model"
|
||||
)
|
||||
assert contents(graph.read_log(forked, "messages")) == ["a", "reply to a", "c"]
|
||||
graph.invoke(Command(goto="model"), forked)
|
||||
head = graph.get_state(config).config
|
||||
assert contents(graph.read_log(head, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"c",
|
||||
"reply to c",
|
||||
]
|
||||
assert contents(graph.read_log(original, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
|
||||
|
||||
def test_subgraph_has_its_own_log(saver: BaseCheckpointSaver) -> None:
|
||||
class Sub(TypedDict):
|
||||
notes: Annotated[list, Log()]
|
||||
|
||||
def note(state: Sub) -> dict:
|
||||
return {"notes": [{"n": len(state.get("notes", []))}]}
|
||||
|
||||
sub_builder = StateGraph(Sub)
|
||||
sub_builder.add_node("note", note)
|
||||
sub_builder.add_edge(START, "note")
|
||||
sub = sub_builder.compile(checkpointer=True)
|
||||
|
||||
class Parent(TypedDict):
|
||||
messages: Annotated[list, MessagesLog()]
|
||||
|
||||
def call_sub(state: Parent) -> dict:
|
||||
sub.invoke({"notes": [{"from": "parent"}]})
|
||||
return {"messages": [AIMessage("done")]}
|
||||
|
||||
builder = StateGraph(Parent)
|
||||
builder.add_node("call_sub", call_sub)
|
||||
builder.add_edge(START, "call_sub")
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("go")]}, config)
|
||||
graph.invoke({"messages": [HumanMessage("again")]}, config)
|
||||
assert contents(graph.read_log(config, "messages")) == [
|
||||
"go",
|
||||
"done",
|
||||
"again",
|
||||
"done",
|
||||
]
|
||||
sub_config = {"configurable": {"thread_id": "1", "checkpoint_ns": "call_sub"}}
|
||||
assert contents(saver.read_log(sub_config, "notes")) == [
|
||||
{"from": "parent"},
|
||||
{"n": 1},
|
||||
{"from": "parent"},
|
||||
{"n": 3},
|
||||
]
|
||||
|
||||
|
||||
def test_exit_durability_commits_inline(saver: BaseCheckpointSaver) -> None:
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config, durability="exit")
|
||||
graph.invoke({"messages": [HumanMessage("b")]}, config, durability="exit")
|
||||
assert contents(graph.read_log(config, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
|
||||
|
||||
def test_prune_keeps_the_heads_transcript() -> None:
|
||||
saver = InMemorySaver()
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
graph.invoke({"messages": [HumanMessage("b")]}, config)
|
||||
original = graph.get_state(config).config
|
||||
before_reply = next(
|
||||
s
|
||||
for s in graph.get_state_history(config)
|
||||
if s.next == ("model",) and graph.read_log(s.config, "messages").length == 3
|
||||
)
|
||||
graph.invoke(None, before_reply.config)
|
||||
log = saver.log.logs[("1", "", "messages")]
|
||||
assert len(log.segments) == 2
|
||||
|
||||
saver.prune(["1"], strategy="keep_latest")
|
||||
assert len(saver.storage["1"][""]) == 1
|
||||
# the head's full transcript survives without its ancestors' checkpoints
|
||||
assert contents(graph.read_log(config, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
# the abandoned branch is collected
|
||||
log = saver.log.logs[("1", "", "messages")]
|
||||
assert sum(len(r) for r in log.entries.values()) == 4
|
||||
assert len(log.steps) == 4
|
||||
assert graph.read_log(original, "messages").length == 0 # its checkpoint is gone
|
||||
|
||||
# the thread keeps working after prune
|
||||
graph.invoke({"messages": [HumanMessage("c")]}, config)
|
||||
assert graph.read_log(config, "messages").length == 6
|
||||
|
||||
saver.prune(["1"], strategy="delete")
|
||||
assert ("1", "", "messages") not in saver.log.logs
|
||||
|
||||
|
||||
def test_rollback_collects_the_runs_entries() -> None:
|
||||
saver = InMemorySaver()
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage("a")]}, {**config, "metadata": {"run_id": "r1"}}
|
||||
)
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage("b")]}, {**config, "metadata": {"run_id": "r2"}}
|
||||
)
|
||||
saver.delete_for_runs(["r2"])
|
||||
assert contents(graph.read_log(config, "messages")) == ["a", "reply to a"]
|
||||
log = saver.log.logs[("1", "", "messages")]
|
||||
assert sum(len(r) for r in log.entries.values()) == 2
|
||||
# the tip moved back: the next run continues the same segment
|
||||
graph.invoke({"messages": [HumanMessage("c")]}, config)
|
||||
assert contents(graph.read_log(config, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"c",
|
||||
"reply to c",
|
||||
]
|
||||
assert len(saver.log.logs[("1", "", "messages")].segments) == 1
|
||||
|
||||
|
||||
def test_copy_and_delete_thread() -> None:
|
||||
saver = InMemorySaver()
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
saver.copy_thread("1", "2")
|
||||
copy_config = {"configurable": {"thread_id": "2"}}
|
||||
graph.invoke({"messages": [HumanMessage("b")]}, copy_config)
|
||||
assert contents(graph.read_log(config, "messages")) == ["a", "reply to a"]
|
||||
assert contents(graph.read_log(copy_config, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
saver.delete_thread("1")
|
||||
assert ("1", "", "messages") not in saver.log.logs
|
||||
assert graph.read_log(copy_config, "messages").length == 4
|
||||
|
||||
|
||||
def test_log_takes_only_json(saver: BaseCheckpointSaver) -> None:
|
||||
class State(TypedDict):
|
||||
events: Annotated[list, Log()]
|
||||
|
||||
def bad(state: State) -> dict:
|
||||
return {"events": [{"when": object()}]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("bad", bad)
|
||||
builder.add_edge(START, "bad")
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
with pytest.raises(InvalidUpdateError, match="not JSON"):
|
||||
graph.invoke({"events": [{"ok": 1}]}, {"configurable": {"thread_id": "1"}})
|
||||
with pytest.raises(InvalidUpdateError, match="not JSON"):
|
||||
graph.invoke({"events": [float("nan")]}, {"configurable": {"thread_id": "2"}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"write",
|
||||
[
|
||||
pytest.param(lambda s: [RemoveMessage(id=s["messages"][0].id)], id="remove"),
|
||||
pytest.param(lambda s: Overwrite([AIMessage("x")]), id="overwrite"),
|
||||
pytest.param(lambda s: [s["messages"][0]], id="replace-by-id"),
|
||||
pytest.param(lambda s: s["messages"] + [AIMessage("x")], id="whole-list"),
|
||||
],
|
||||
)
|
||||
def test_messages_log_rejects_edits(saver: BaseCheckpointSaver, write: Any) -> None:
|
||||
def edits(state: ChatState) -> dict:
|
||||
return {"messages": write(state)}
|
||||
|
||||
builder = StateGraph(ChatState)
|
||||
builder.add_node("edits", edits)
|
||||
builder.add_edge(START, "edits")
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage("a")]}, {"configurable": {"thread_id": "1"}}
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_and_native_read_the_same_log() -> None:
|
||||
def run(saver: BaseCheckpointSaver) -> list[list[Any]]:
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
for q in "abc":
|
||||
graph.invoke({"messages": [HumanMessage(q)]}, config)
|
||||
return [
|
||||
contents(graph.read_log(s.config, "messages"))
|
||||
for s in graph.get_state_history(config)
|
||||
]
|
||||
|
||||
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
try:
|
||||
assert run(InMemorySaver()) == run(SqliteSaver(conn))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_resume_at_a_superseded_checkpoint_branches(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""A resume at a checkpoint the thread moved past re-runs finished tasks on
|
||||
that same checkpoint, without a fork checkpoint. The log puts the re-run
|
||||
in a new segment and leaves the original branch alone."""
|
||||
calls: list[str] = []
|
||||
|
||||
class State(TypedDict):
|
||||
events: Annotated[list, Log()]
|
||||
|
||||
def fast(state: State) -> dict:
|
||||
calls.append("fast")
|
||||
return {"events": [{"from": "fast", "run": len(calls)}]}
|
||||
|
||||
def asks(state: State) -> dict:
|
||||
answer = interrupt("question")
|
||||
return {"events": [{"from": "asks", "answer": answer}]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("fast", fast)
|
||||
builder.add_node("asks", asks)
|
||||
builder.add_edge(START, "fast")
|
||||
builder.add_edge(START, "asks")
|
||||
builder.add_edge(["fast", "asks"], END)
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"events": [{"from": "input"}]}, config)
|
||||
interrupted = graph.get_state(config).config
|
||||
first = graph.invoke(Command(resume="yes"), config)["events"]
|
||||
original_head = graph.get_state(config).config
|
||||
|
||||
second = graph.invoke(Command(resume="no"), interrupted)["events"]
|
||||
assert calls == ["fast", "fast"] # the finished task re-ran
|
||||
new_head = graph.get_state(config).config
|
||||
assert contents(graph.read_log(new_head, "events")) == second
|
||||
assert contents(graph.read_log(original_head, "events")) == first
|
||||
assert first != second
|
||||
if isinstance(saver, InMemorySaver):
|
||||
assert len(saver.log.logs[("1", "", "events")].segments) == 2
|
||||
|
||||
|
||||
def test_tool_calling_agent_runs_on_a_messages_log(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Prebuilt `ToolNode` and `tools_condition` work unchanged: they read the
|
||||
list and append messages."""
|
||||
|
||||
@tool
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
def model(state: ChatState) -> dict:
|
||||
last = state["messages"][-1]
|
||||
if isinstance(last, HumanMessage):
|
||||
call = {
|
||||
"name": "add",
|
||||
"args": {"a": 2, "b": 3},
|
||||
"id": f"call-{len(state['messages'])}",
|
||||
}
|
||||
return {"messages": [AIMessage("", tool_calls=[call])]}
|
||||
return {"messages": [AIMessage(f"the answer is {last.content}")]}
|
||||
|
||||
builder = StateGraph(ChatState)
|
||||
builder.add_node("model", model)
|
||||
builder.add_node("tools", ToolNode([add]))
|
||||
builder.add_edge(START, "model")
|
||||
builder.add_conditional_edges("model", tools_condition)
|
||||
builder.add_edge("tools", "model")
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
out = graph.invoke({"messages": [HumanMessage("2+3?")]}, config)
|
||||
assert [type(m).__name__ for m in out["messages"]] == [
|
||||
"HumanMessage",
|
||||
"AIMessage",
|
||||
"ToolMessage",
|
||||
"AIMessage",
|
||||
]
|
||||
page = graph.read_log(config, "messages")
|
||||
assert [m.id for _, m in page.entries] == [m.id for m in out["messages"]]
|
||||
assert page.entries[-1][1].content == "the answer is 5"
|
||||
|
||||
|
||||
def test_input_after_update_state_reads_the_inline_step(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""`update_state` commits its entries inline; the input checkpoint after
|
||||
it keeps the version with a stripped marker, and reads must still find
|
||||
the entries on the checkpoint that committed them."""
|
||||
graph = chat_graph(saver)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
graph.update_state(config, {"messages": [HumanMessage("note")]})
|
||||
graph.invoke({"messages": [HumanMessage("b")]}, config)
|
||||
assert contents(graph.read_log(config, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"note",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
|
||||
|
||||
# -- existing `add_messages` threads ------------------------------------------
|
||||
|
||||
|
||||
class OldChatState(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
|
||||
|
||||
def _reply(state: dict) -> dict:
|
||||
return {"messages": [AIMessage(f"reply to {state['messages'][-1].content}")]}
|
||||
|
||||
|
||||
def _chat(schema: type, saver: BaseCheckpointSaver, **nodes: Any):
|
||||
builder = StateGraph(schema)
|
||||
builder.add_node("model", nodes.get("model", _reply))
|
||||
builder.add_edge(START, "model")
|
||||
if "review" in nodes:
|
||||
builder.add_node("review", nodes["review"])
|
||||
builder.add_edge("model", "review")
|
||||
builder.add_edge("review", END)
|
||||
else:
|
||||
builder.add_edge("model", END)
|
||||
return builder.compile(checkpointer=saver)
|
||||
|
||||
|
||||
def test_add_messages_thread_switches_to_messages_log(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""A thread written with `add_messages` keeps working on `MessagesLog`:
|
||||
the stored list is the log's first entries."""
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
old = _chat(OldChatState, saver)
|
||||
old.invoke({"messages": [HumanMessage("a", id="h1")]}, config)
|
||||
old.invoke({"messages": [HumanMessage("b", id="h2")]}, config)
|
||||
before_switch = old.get_state(config).config
|
||||
|
||||
new = _chat(ChatState, saver)
|
||||
out = new.invoke({"messages": [HumanMessage("c", id="h3")]}, config)
|
||||
assert [m.content for m in out["messages"]] == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
"c",
|
||||
"reply to c",
|
||||
]
|
||||
assert new.get_state(config).values == {"messages": LogRef(6)}
|
||||
page = new.read_log(config, "messages")
|
||||
assert [p for p, _ in page.entries] == list(range(6))
|
||||
assert contents(page) == [m.content for m in out["messages"]]
|
||||
assert [m.id for _, m in page.entries][:1] == ["h1"]
|
||||
# checkpoints from before the switch still read as the list they stored
|
||||
assert contents(new.read_log(before_switch, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"b",
|
||||
"reply to b",
|
||||
]
|
||||
assert len(new.get_state(before_switch).values["messages"]) == 4
|
||||
# the next turn appends after the seed
|
||||
new.invoke({"messages": [HumanMessage("d")]}, config)
|
||||
assert new.read_log(config, "messages").length == 8
|
||||
if isinstance(saver, InMemorySaver):
|
||||
log = saver.log.logs[("1", "", "messages")]
|
||||
assert sum(len(rows) for rows in log.entries.values()) == 8
|
||||
|
||||
|
||||
def test_switch_with_an_interrupted_add_messages_run(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""The first checkpoint after the switch can come from the loop (a
|
||||
resume), not graph input; the seed is committed with it."""
|
||||
|
||||
def review(state: dict) -> dict:
|
||||
answer = interrupt("approve?")
|
||||
return {"messages": [AIMessage(f"review: {answer}")]}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
old = _chat(OldChatState, saver, review=review)
|
||||
old.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
assert old.get_state(config).next == ("review",)
|
||||
|
||||
new = _chat(ChatState, saver, review=review)
|
||||
out = new.invoke(Command(resume="ok"), config)
|
||||
assert [m.content for m in out["messages"]] == ["a", "reply to a", "review: ok"]
|
||||
assert contents(new.read_log(config, "messages")) == [
|
||||
"a",
|
||||
"reply to a",
|
||||
"review: ok",
|
||||
]
|
||||
|
||||
|
||||
def test_switch_then_update_state(saver: BaseCheckpointSaver) -> None:
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
old = _chat(OldChatState, saver)
|
||||
old.invoke({"messages": [HumanMessage("a")]}, config)
|
||||
new = _chat(ChatState, saver)
|
||||
new.update_state(config, {"messages": [HumanMessage("note")]})
|
||||
assert contents(new.read_log(config, "messages")) == ["a", "reply to a", "note"]
|
||||
out = new.invoke({"messages": [HumanMessage("b")]}, config)
|
||||
assert [m.content for m in out["messages"]][-2:] == ["b", "reply to b"]
|
||||
assert new.read_log(config, "messages").length == 5
|
||||
|
||||
|
||||
# -- deprecation of message edit ops on `add_messages` -------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"edit",
|
||||
[
|
||||
pytest.param(lambda s: [RemoveMessage(id=s["messages"][0].id)], id="remove"),
|
||||
pytest.param(
|
||||
lambda s: [RemoveMessage(id=REMOVE_ALL_MESSAGES), AIMessage("x")],
|
||||
id="remove-all",
|
||||
),
|
||||
pytest.param(
|
||||
lambda s: [HumanMessage("edited", id=s["messages"][0].id)],
|
||||
id="replace-by-id",
|
||||
),
|
||||
pytest.param(lambda s: Overwrite([AIMessage("x")]), id="overwrite"),
|
||||
],
|
||||
)
|
||||
def test_add_messages_edit_ops_are_deprecated(edit: Any) -> None:
|
||||
def node(state: dict) -> dict:
|
||||
return {"messages": edit(state)}
|
||||
|
||||
graph = _chat(OldChatState, InMemorySaver(), model=node)
|
||||
with pytest.warns(LangGraphDeprecatedSinceV13, match="MessagesLog"):
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage("a")]}, {"configurable": {"thread_id": "1"}}
|
||||
)
|
||||
|
||||
|
||||
def test_add_messages_appends_are_not_deprecated() -> None:
|
||||
graph = _chat(OldChatState, InMemorySaver())
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", LangGraphDeprecatedSinceV13)
|
||||
graph.invoke(
|
||||
{"messages": [HumanMessage("a")]}, {"configurable": {"thread_id": "1"}}
|
||||
)
|
||||
@@ -36,7 +36,7 @@ from langchain_core.runnables import (
|
||||
RunnablePassthrough,
|
||||
)
|
||||
from langchain_core.runnables.graph import Edge
|
||||
from langchain_core.tools import StructuredTool, tool
|
||||
from langchain_core.tools import tool
|
||||
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
|
||||
from langgraph.cache.base import BaseCache
|
||||
from langgraph.checkpoint.base import (
|
||||
@@ -3105,49 +3105,6 @@ def test_simple_multi_edge(snapshot: SnapshotAssertion) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_tool_node_lists_tool_subagents_in_graph_metadata() -> None:
|
||||
class TaskTool(StructuredTool):
|
||||
subagents: list[dict[str, Any]] = []
|
||||
|
||||
def run_task(description: str, subagent_type: str) -> str:
|
||||
return subagent_type
|
||||
|
||||
task = TaskTool.from_function(run_task, name="task", description="Run a subagent.")
|
||||
task.subagents = [
|
||||
{"name": "researcher", "description": "Researches a topic."},
|
||||
{
|
||||
"name": "critic",
|
||||
"description": "Reviews drafts.",
|
||||
"forked": True,
|
||||
"system_prompt": "You are a critic.",
|
||||
"runnable": object(),
|
||||
},
|
||||
{"name": "planner", "description": None, "forked": "yes"},
|
||||
]
|
||||
|
||||
@tool
|
||||
def search(query: str) -> str:
|
||||
"""Search the web."""
|
||||
return query
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("agent", lambda state: state)
|
||||
builder.add_node("tools", ToolNode([search, task]))
|
||||
builder.add_edge(START, "agent")
|
||||
builder.add_edge("agent", "tools")
|
||||
graph = builder.compile().get_graph().to_json()
|
||||
nodes = {node["id"]: node for node in graph["nodes"]}
|
||||
|
||||
assert nodes["tools"]["metadata"]["__subagents"] == {
|
||||
"task": [
|
||||
{"name": "researcher", "description": "Researches a topic."},
|
||||
{"name": "critic", "description": "Reviews drafts.", "forked": True},
|
||||
{"name": "planner"},
|
||||
]
|
||||
}
|
||||
assert "__subagents" not in (nodes["agent"].get("metadata") or {})
|
||||
|
||||
|
||||
def test_nested_graph_xray(snapshot: SnapshotAssertion) -> None:
|
||||
class State(TypedDict):
|
||||
my_key: Annotated[str, operator.add]
|
||||
@@ -8993,6 +8950,39 @@ def test_subgraph_streaming_sync() -> None:
|
||||
assert result["num_chunks"] == 9
|
||||
|
||||
|
||||
def test_subgraph_streaming_node_output_messages_sync() -> None:
|
||||
"""Messages returned by a node (not streamed by an LLM) are emitted when a
|
||||
graph is explicitly streamed with `stream_mode="messages"` inside a node."""
|
||||
|
||||
def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage("hi there", id="ai1")]}
|
||||
|
||||
subgraph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "respond")
|
||||
.compile()
|
||||
)
|
||||
msgs = {"messages": [("user", "hello")]}
|
||||
|
||||
top_level = [msg for msg, _ in subgraph.stream(msgs, stream_mode="messages")]
|
||||
|
||||
def parent_node(state: MessagesState, config: RunnableConfig) -> dict:
|
||||
nonlocal in_node
|
||||
in_node = [
|
||||
msg for msg, _ in subgraph.stream(msgs, config, stream_mode="messages")
|
||||
]
|
||||
return {}
|
||||
|
||||
in_node: list = []
|
||||
StateGraph(MessagesState).add_node("parent", parent_node).add_edge(
|
||||
START, "parent"
|
||||
).compile().invoke(msgs)
|
||||
|
||||
assert [m.content for m in top_level] == ["hi there"]
|
||||
assert [m.content for m in in_node] == ["hi there"]
|
||||
|
||||
|
||||
def test_get_graph_nonterminal_last_step_source(snapshot: SnapshotAssertion) -> None:
|
||||
class State(TypedDict):
|
||||
messages: list[str]
|
||||
|
||||
@@ -9503,6 +9503,44 @@ async def test_subgraph_streaming_async() -> None:
|
||||
assert result["num_chunks"] == 9
|
||||
|
||||
|
||||
async def test_subgraph_streaming_node_output_messages_async() -> None:
|
||||
"""Messages returned by a node (not streamed by an LLM) are emitted when a
|
||||
graph is explicitly streamed with `stream_mode="messages"` inside a node."""
|
||||
|
||||
async def respond(state: MessagesState) -> dict:
|
||||
return {"messages": [AIMessage("hi there", id="ai1")]}
|
||||
|
||||
subgraph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("respond", respond)
|
||||
.add_edge(START, "respond")
|
||||
.compile()
|
||||
)
|
||||
msgs = {"messages": [("user", "hello")]}
|
||||
|
||||
top_level = [msg async for msg, _ in subgraph.astream(msgs, stream_mode="messages")]
|
||||
|
||||
async def parent_node(state: MessagesState, config: RunnableConfig) -> dict:
|
||||
nonlocal in_node
|
||||
in_node = [
|
||||
msg
|
||||
async for msg, _ in subgraph.astream(msgs, config, stream_mode="messages")
|
||||
]
|
||||
return {}
|
||||
|
||||
in_node: list = []
|
||||
await (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("parent", parent_node)
|
||||
.add_edge(START, "parent")
|
||||
.compile()
|
||||
.ainvoke(msgs)
|
||||
)
|
||||
|
||||
assert [m.content for m in top_level] == ["hi there"]
|
||||
assert [m.content for m in in_node] == ["hi there"]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_null_resume_disallowed_with_multiple_interrupts(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
|
||||
@@ -16,9 +16,11 @@ from langgraph_sdk.schema import StreamPart
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph._internal._constants import CONFIG_KEY_STREAM
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
|
||||
from langgraph.pregel import Pregel
|
||||
from langgraph.pregel.protocol import StreamProtocol
|
||||
from langgraph.pregel.remote import RemoteGraph
|
||||
from langgraph.types import Interrupt, StateSnapshot
|
||||
from tests.any_str import AnyStr
|
||||
@@ -863,6 +865,87 @@ async def test_astream_subgraphs_keeps_caller_namespace():
|
||||
assert stream_parts == _CALLER_PREFIXED
|
||||
|
||||
|
||||
# Subgraph events requested only on behalf of a streaming parent graph
|
||||
_PARENT_STREAMED_PARTS = [
|
||||
StreamPart(event="updates|sub:abc", data={"inner": {"chunk": "inner"}}),
|
||||
StreamPart(event="messages|sub:abc", data=[{"content": "token"}, {}]),
|
||||
StreamPart(event="updates", data={"sub": {"chunk": "root"}}),
|
||||
]
|
||||
|
||||
|
||||
def _parent_streamed_config(parent_parts: list) -> RunnableConfig:
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": "thread_1",
|
||||
"checkpoint_ns": "outer:123",
|
||||
CONFIG_KEY_STREAM: StreamProtocol(
|
||||
parent_parts.append, {"updates", "messages"}
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_stream_without_subgraphs_skips_parent_requested_subgraph_events():
|
||||
mock_sync_client = MagicMock()
|
||||
mock_sync_client.runs.stream.return_value = _PARENT_STREAMED_PARTS
|
||||
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
|
||||
|
||||
for stream_mode, expected in [
|
||||
("updates", [{"sub": {"chunk": "root"}}]),
|
||||
# messages are not filtered: the server dedupes them across namespaces
|
||||
("messages", [({"content": "token"}, {})]),
|
||||
]:
|
||||
parent_parts: list = []
|
||||
stream_parts = list(
|
||||
remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
_parent_streamed_config(parent_parts),
|
||||
stream_mode=stream_mode,
|
||||
)
|
||||
)
|
||||
|
||||
assert stream_parts == expected
|
||||
# subgraph events are still requested and forwarded to the parent
|
||||
assert mock_sync_client.runs.stream.call_args.kwargs["stream_subgraphs"]
|
||||
assert [ns for ns, _, _ in parent_parts] == [
|
||||
("outer:123", "sub:abc"),
|
||||
("outer:123", "sub:abc"),
|
||||
("outer:123",),
|
||||
]
|
||||
|
||||
|
||||
async def test_astream_without_subgraphs_skips_parent_requested_subgraph_events():
|
||||
mock_async_client = MagicMock()
|
||||
async_iter = MagicMock()
|
||||
async_iter.__aiter__.return_value = _PARENT_STREAMED_PARTS
|
||||
mock_async_client.runs.stream.return_value = async_iter
|
||||
remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client)
|
||||
|
||||
for stream_mode, expected in [
|
||||
("updates", [{"sub": {"chunk": "root"}}]),
|
||||
# messages are not filtered: the server dedupes them across namespaces
|
||||
("messages", [({"content": "token"}, {})]),
|
||||
]:
|
||||
parent_parts: list = []
|
||||
stream_parts = [
|
||||
part
|
||||
async for part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
_parent_streamed_config(parent_parts),
|
||||
stream_mode=stream_mode,
|
||||
)
|
||||
]
|
||||
|
||||
assert stream_parts == expected
|
||||
# subgraph events are still requested and forwarded to the parent
|
||||
assert mock_async_client.runs.stream.call_args.kwargs["stream_subgraphs"]
|
||||
assert [ns for ns, _, _ in parent_parts] == [
|
||||
("outer:123", "sub:abc"),
|
||||
("outer:123", "sub:abc"),
|
||||
("outer:123",),
|
||||
]
|
||||
|
||||
|
||||
def test_invoke():
|
||||
# set up test
|
||||
mock_sync_client = MagicMock()
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from langgraph.func import entrypoint, task
|
||||
from langgraph.graph import START, MessagesState, StateGraph
|
||||
from langgraph.graph.message import push_message
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.11+ is required for async contextvars support",
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def echo(text: str) -> str:
|
||||
"""Echo the text."""
|
||||
return text
|
||||
|
||||
|
||||
def _one_node_graph(node: Any) -> CompiledStateGraph:
|
||||
return (
|
||||
StateGraph(MessagesState)
|
||||
.add_node(node)
|
||||
.add_edge(START, node.__name__)
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
def _chat_and_echo(state: MessagesState) -> dict:
|
||||
echo.invoke({"text": "done"})
|
||||
return {"messages": [GenericFakeChatModel(messages=iter(["one two"])).invoke("hi")]}
|
||||
|
||||
|
||||
async def _achat_and_echo(state: MessagesState) -> dict:
|
||||
await echo.ainvoke({"text": "done"})
|
||||
model = GenericFakeChatModel(messages=iter(["one two"]))
|
||||
return {"messages": [await model.ainvoke("hi")]}
|
||||
|
||||
|
||||
def _segment_names(ns: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(segment.split(":")[0] for segment in ns)
|
||||
|
||||
|
||||
def _summary(part: dict) -> tuple[tuple[str, ...], str]:
|
||||
if part["type"] == "messages":
|
||||
return _segment_names(part["ns"]), part["data"][0].content
|
||||
return _segment_names(part["ns"]), part["data"]["event"]
|
||||
|
||||
|
||||
_CHAT_AND_ECHO_EVENTS = [
|
||||
((), "tool-started"),
|
||||
((), "tool-finished"),
|
||||
((), "one"),
|
||||
((), " "),
|
||||
((), "two"),
|
||||
]
|
||||
|
||||
_CHILD_CONFIGS = pytest.mark.parametrize(
|
||||
"child_config",
|
||||
[
|
||||
None,
|
||||
{"configurable": {"thread_id": "child"}},
|
||||
{"configurable": {"thread_id": "parent"}},
|
||||
],
|
||||
ids=["no_config", "own_thread", "caller_thread"],
|
||||
)
|
||||
|
||||
|
||||
_TASK_CALLERS = pytest.mark.parametrize(
|
||||
"nested", [False, True], ids=["from_entrypoint", "from_task_in_task"]
|
||||
)
|
||||
|
||||
|
||||
@_TASK_CALLERS
|
||||
@pytest.mark.parametrize("subgraphs", [False, True])
|
||||
def test_task_events_stream_under_the_graph_namespace(
|
||||
nested: bool, subgraphs: bool
|
||||
) -> None:
|
||||
@task
|
||||
def chat_and_echo() -> None:
|
||||
_chat_and_echo({"messages": []})
|
||||
|
||||
@task
|
||||
def outer() -> None:
|
||||
chat_and_echo().result()
|
||||
|
||||
@entrypoint()
|
||||
def workflow(inputs: dict) -> None:
|
||||
(outer() if nested else chat_and_echo()).result()
|
||||
|
||||
parts = workflow.stream(
|
||||
{}, stream_mode=["messages", "tools"], subgraphs=subgraphs, version="v2"
|
||||
)
|
||||
|
||||
assert [_summary(part) for part in parts] == _CHAT_AND_ECHO_EVENTS
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@_TASK_CALLERS
|
||||
@pytest.mark.parametrize("subgraphs", [False, True])
|
||||
async def test_task_events_stream_under_the_graph_namespace_async(
|
||||
nested: bool, subgraphs: bool
|
||||
) -> None:
|
||||
@task
|
||||
async def chat_and_echo() -> None:
|
||||
await _achat_and_echo({"messages": []})
|
||||
|
||||
@task
|
||||
async def outer() -> None:
|
||||
await chat_and_echo()
|
||||
|
||||
@entrypoint()
|
||||
async def workflow(inputs: dict) -> None:
|
||||
await (outer() if nested else chat_and_echo())
|
||||
|
||||
parts = workflow.astream(
|
||||
{}, stream_mode=["messages", "tools"], subgraphs=subgraphs, version="v2"
|
||||
)
|
||||
|
||||
assert [_summary(part) async for part in parts] == _CHAT_AND_ECHO_EVENTS
|
||||
|
||||
|
||||
def test_task_events_stream_under_the_namespace_of_their_subgraph() -> None:
|
||||
@task
|
||||
def chat_and_echo() -> None:
|
||||
_chat_and_echo({"messages": []})
|
||||
|
||||
def node(state: MessagesState) -> dict:
|
||||
chat_and_echo().result()
|
||||
return {}
|
||||
|
||||
sub = _one_node_graph(node)
|
||||
parent = StateGraph(MessagesState).add_node("sub", sub).add_edge(START, "sub")
|
||||
|
||||
parts = parent.compile().stream(
|
||||
{"messages": []},
|
||||
stream_mode=["messages", "tools"],
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
)
|
||||
|
||||
assert [_summary(part) for part in parts] == [
|
||||
(("sub",), event) for _, event in _CHAT_AND_ECHO_EVENTS
|
||||
]
|
||||
|
||||
|
||||
@_CHILD_CONFIGS
|
||||
def test_child_graph_streams_only_to_the_node_streaming_it(
|
||||
child_config: dict | None,
|
||||
) -> None:
|
||||
child = _one_node_graph(_chat_and_echo)
|
||||
received = []
|
||||
|
||||
def node(state: MessagesState) -> dict:
|
||||
received.extend(
|
||||
child.stream(
|
||||
{"messages": []}, child_config, stream_mode=["messages", "tools"]
|
||||
)
|
||||
)
|
||||
return {}
|
||||
|
||||
parts = _one_node_graph(node).stream(
|
||||
{"messages": []},
|
||||
{"configurable": {"thread_id": "parent"}},
|
||||
stream_mode=["messages", "tools"],
|
||||
)
|
||||
|
||||
assert list(parts) == []
|
||||
assert len(received) == len(_CHAT_AND_ECHO_EVENTS)
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@_CHILD_CONFIGS
|
||||
async def test_child_graph_streams_only_to_the_node_streaming_it_async(
|
||||
child_config: dict | None,
|
||||
) -> None:
|
||||
child = _one_node_graph(_achat_and_echo)
|
||||
received = []
|
||||
|
||||
async def node(state: MessagesState) -> dict:
|
||||
stream = child.astream(
|
||||
{"messages": []}, child_config, stream_mode=["messages", "tools"]
|
||||
)
|
||||
received.extend([part async for part in stream])
|
||||
return {}
|
||||
|
||||
parts = _one_node_graph(node).astream(
|
||||
{"messages": []},
|
||||
{"configurable": {"thread_id": "parent"}},
|
||||
stream_mode=["messages", "tools"],
|
||||
)
|
||||
|
||||
assert [part async for part in parts] == []
|
||||
assert len(received) == len(_CHAT_AND_ECHO_EVENTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"child_config",
|
||||
[
|
||||
{"configurable": {"thread_id": "child"}},
|
||||
{"configurable": {"thread_id": "parent"}},
|
||||
],
|
||||
ids=["own_thread", "caller_thread"],
|
||||
)
|
||||
def test_child_graph_on_its_own_thread_skips_a_caller_streaming_subgraphs(
|
||||
child_config: dict,
|
||||
) -> None:
|
||||
child = _one_node_graph(_chat_and_echo)
|
||||
|
||||
def node(state: MessagesState) -> dict:
|
||||
child.invoke({"messages": []}, child_config)
|
||||
return {}
|
||||
|
||||
parts = _one_node_graph(node).stream(
|
||||
{"messages": []},
|
||||
{"configurable": {"thread_id": "parent"}},
|
||||
stream_mode=["messages", "tools"],
|
||||
subgraphs=True,
|
||||
)
|
||||
|
||||
assert list(parts) == []
|
||||
|
||||
|
||||
def test_node_gets_the_events_of_every_subgraph_it_streams() -> None:
|
||||
sub = _one_node_graph(_chat_and_echo)
|
||||
counts = []
|
||||
|
||||
def node(state: MessagesState) -> dict:
|
||||
for _ in range(2):
|
||||
stream = sub.stream({"messages": []}, stream_mode=["messages", "tools"])
|
||||
counts.append(len(list(stream)))
|
||||
return {}
|
||||
|
||||
_one_node_graph(node).invoke({"messages": []})
|
||||
|
||||
assert counts == [len(_CHAT_AND_ECHO_EVENTS)] * 2
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_gets_the_events_of_every_subgraph_it_streams_async() -> None:
|
||||
sub = _one_node_graph(_achat_and_echo)
|
||||
counts = []
|
||||
|
||||
async def node(state: MessagesState) -> dict:
|
||||
for _ in range(2):
|
||||
stream = sub.astream({"messages": []}, stream_mode=["messages", "tools"])
|
||||
counts.append(len([part async for part in stream]))
|
||||
return {}
|
||||
|
||||
await _one_node_graph(node).ainvoke({"messages": []})
|
||||
|
||||
assert counts == [len(_CHAT_AND_ECHO_EVENTS)] * 2
|
||||
|
||||
|
||||
def _push(state: MessagesState) -> dict:
|
||||
push_message(AIMessage(id="pushed", content="hi"), state_key=None)
|
||||
return {}
|
||||
|
||||
|
||||
def _namespaces(graph: CompiledStateGraph, subgraphs: bool) -> list[tuple[str, ...]]:
|
||||
parts = graph.stream(
|
||||
{"messages": []}, stream_mode="messages", subgraphs=subgraphs, version="v2"
|
||||
)
|
||||
return [_segment_names(part["ns"]) for part in parts]
|
||||
|
||||
|
||||
def _streams_pushing_graph(received: list) -> Any:
|
||||
pushing = _one_node_graph(_push)
|
||||
|
||||
def node(state: MessagesState) -> dict:
|
||||
for message, _ in pushing.stream({"messages": []}, stream_mode="messages"):
|
||||
received.append(message.id)
|
||||
return {}
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def test_pushed_message_streams_under_the_namespace_of_its_graph() -> None:
|
||||
pushing = _one_node_graph(_push)
|
||||
parent = StateGraph(MessagesState).add_node("sub", pushing).add_edge(START, "sub")
|
||||
|
||||
assert _namespaces(pushing, subgraphs=True) == [()]
|
||||
assert _namespaces(parent.compile(), subgraphs=False) == []
|
||||
assert _namespaces(parent.compile(), subgraphs=True) == [("sub",)]
|
||||
|
||||
|
||||
def test_pushed_message_streams_to_the_node_that_streams_its_graph() -> None:
|
||||
received: list[str] = []
|
||||
graph = _one_node_graph(_streams_pushing_graph(received))
|
||||
|
||||
assert _namespaces(graph, subgraphs=False) == []
|
||||
assert received == ["pushed"]
|
||||
|
||||
|
||||
def test_pushed_message_streams_to_every_stream_that_takes_it() -> None:
|
||||
received: list[str] = []
|
||||
graph = _one_node_graph(_streams_pushing_graph(received))
|
||||
|
||||
assert _namespaces(graph, subgraphs=True) == [("node",)]
|
||||
assert received == ["pushed"]
|
||||
@@ -1,11 +1,24 @@
|
||||
"""End-to-end tests for `TracePolicy` input processing on node trace runs."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph._internal._runnable import (
|
||||
ASYNCIO_ACCEPTS_CONTEXT,
|
||||
RunnableCallable,
|
||||
RunnableSeq,
|
||||
)
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
from langgraph.types import TracePolicy
|
||||
from tests.fake_tracer import FakeTracer, Run
|
||||
|
||||
@@ -147,3 +160,179 @@ async def test_trace_policy_transforms_recorded_inputs_async() -> None:
|
||||
run = _node_run(tracer, "n")
|
||||
assert run.inputs == {"scrubbed_in": True}
|
||||
assert run.outputs == {"value": 6}
|
||||
|
||||
|
||||
_RECORDING_POLICY = TracePolicy(
|
||||
process_inputs=lambda _: {"recorded": "inputs"},
|
||||
process_outputs=lambda _: {"recorded": "outputs"},
|
||||
)
|
||||
|
||||
|
||||
def _chat_graph() -> CompiledStateGraph:
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[AIMessage("model reply", id="ai-model")]
|
||||
)
|
||||
|
||||
def chat(state: MessagesState) -> dict[str, Any]:
|
||||
reply = model.invoke(state["messages"])
|
||||
return {
|
||||
"messages": [
|
||||
*state["messages"],
|
||||
reply,
|
||||
AIMessage("from node", id="ai-node"),
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("chat", chat, trace_policy=_RECORDING_POLICY)
|
||||
.add_edge(START, "chat")
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
def _assert_scrubbed_node_run(tracer: FakeTracer, *, model_is_child: bool) -> None:
|
||||
run = _node_run(tracer, "chat")
|
||||
assert run.inputs == {"recorded": "inputs"}
|
||||
assert run.outputs == {"recorded": "outputs"}
|
||||
if model_is_child:
|
||||
assert [c.name for c in run.child_runs] == ["FakeMessagesListChatModel"]
|
||||
|
||||
|
||||
def test_trace_policy_scrubs_tracer_not_messages_stream() -> None:
|
||||
tracer = FakeTracer()
|
||||
streamed = [
|
||||
message.content
|
||||
for message, _ in _chat_graph().stream(
|
||||
{"messages": [HumanMessage("hello", id="h-1")]},
|
||||
{"callbacks": [tracer]},
|
||||
stream_mode="messages",
|
||||
)
|
||||
]
|
||||
|
||||
assert streamed == ["model reply", "from node"]
|
||||
_assert_scrubbed_node_run(tracer, model_is_child=True)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trace_policy_scrubs_tracer_not_messages_stream_async() -> None:
|
||||
tracer = FakeTracer()
|
||||
streamed = [
|
||||
message.content
|
||||
async for message, _ in _chat_graph().astream(
|
||||
{"messages": [HumanMessage("hello", id="h-1")]},
|
||||
{"callbacks": [tracer]},
|
||||
stream_mode="messages",
|
||||
)
|
||||
]
|
||||
|
||||
assert streamed == ["model reply", "from node"]
|
||||
_assert_scrubbed_node_run(tracer, model_is_child=ASYNCIO_ACCEPTS_CONTEXT)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trace_policy_scrubs_tracer_not_astream_events() -> None:
|
||||
tracer = FakeTracer()
|
||||
chat_events = [
|
||||
event
|
||||
async for event in _chat_graph().astream_events(
|
||||
{"messages": [HumanMessage("hello", id="h-1")]},
|
||||
{"callbacks": [tracer]},
|
||||
version="v2",
|
||||
)
|
||||
if event["name"] == "chat"
|
||||
and event["event"] in ("on_chain_start", "on_chain_end")
|
||||
]
|
||||
|
||||
assert [e["event"] for e in chat_events] == ["on_chain_start", "on_chain_end"]
|
||||
start, end = (e["data"] for e in chat_events)
|
||||
assert [m.content for m in start["input"]["messages"]] == ["hello"]
|
||||
assert [m.content for m in end["output"]["messages"]] == [
|
||||
"hello",
|
||||
"model reply",
|
||||
"from node",
|
||||
]
|
||||
_assert_scrubbed_node_run(tracer, model_is_child=ASYNCIO_ACCEPTS_CONTEXT)
|
||||
|
||||
|
||||
class _HandlerWithTapMethods(BaseCallbackHandler):
|
||||
def __init__(self) -> None:
|
||||
self.node_payloads: list[Any] = []
|
||||
self.node_run_ids: set[UUID] = set()
|
||||
|
||||
def _record(self, run_id: UUID, payload: Any) -> None:
|
||||
if run_id in self.node_run_ids:
|
||||
self.node_payloads.append(payload)
|
||||
|
||||
def tap_output_iter(self, run_id: UUID, output: Iterator[Any]) -> Iterator[Any]:
|
||||
for chunk in output:
|
||||
self._record(run_id, chunk)
|
||||
yield chunk
|
||||
|
||||
async def tap_output_aiter(
|
||||
self, run_id: UUID, output: AsyncIterator[Any]
|
||||
) -> AsyncIterator[Any]:
|
||||
async for chunk in output:
|
||||
self._record(run_id, chunk)
|
||||
yield chunk
|
||||
|
||||
def on_chain_start(
|
||||
self, serialized: Any, inputs: Any, *, run_id: UUID, **kwargs: Any
|
||||
) -> None:
|
||||
if kwargs.get("name") == "n":
|
||||
self.node_run_ids.add(run_id)
|
||||
self._record(run_id, inputs)
|
||||
|
||||
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||
self._record(run_id, outputs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("via_events", [False, True], ids=["ainvoke", "astream_events"])
|
||||
async def test_trace_policy_reaches_tracers_but_not_custom_handlers(
|
||||
via_events: bool,
|
||||
) -> None:
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("n", _incr, trace_policy=_RECORDING_POLICY)
|
||||
.add_edge(START, "n")
|
||||
.add_edge("n", END)
|
||||
.compile()
|
||||
)
|
||||
handler = _HandlerWithTapMethods()
|
||||
tracer = FakeTracer()
|
||||
config: RunnableConfig = {"callbacks": [handler, tracer]}
|
||||
|
||||
if via_events:
|
||||
async for _ in graph.astream_events({"value": 1}, config, version="v2"):
|
||||
pass
|
||||
else:
|
||||
assert await graph.ainvoke({"value": 1}, config) == {"value": 2}
|
||||
assert handler.node_payloads == [{"value": 1}, {"value": 2}]
|
||||
run = _node_run(tracer, "n")
|
||||
assert (run.inputs, run.outputs) == (
|
||||
{"recorded": "inputs"},
|
||||
{"recorded": "outputs"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_trace_policy_cancel_at_node_end_still_closes_tracer_run() -> None:
|
||||
def cancel_own_task(value: Any) -> Any:
|
||||
asyncio.current_task().cancel()
|
||||
return value
|
||||
|
||||
tracer = FakeTracer()
|
||||
seq = RunnableSeq(
|
||||
RunnableCallable(lambda x: x, trace=False),
|
||||
RunnableCallable(cancel_own_task, trace=False),
|
||||
trace_policy=_RECORDING_POLICY,
|
||||
)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.create_task(seq.ainvoke({}, {"callbacks": [tracer]}))
|
||||
for _ in range(100):
|
||||
if tracer.runs:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert [r.outputs for r in tracer.runs] == [{"recorded": "outputs"}]
|
||||
Generated
+36
-37
@@ -1439,7 +1439,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.18"
|
||||
version = "1.2.19"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1722,7 +1722,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
source = { editable = "../checkpoint-sqlite" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -2699,37 +2699,36 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c7/f9374c8939560ab22db1dd9dbe215b62d20f32634aeb7f259dc03c807b44/pycryptodome-4.2.0.tar.gz", hash = "sha256:21d7fdba239d6ef863d3d08388801c683453a3bb4897c2b27f27847aad89bdf7", size = 1750332, upload-time = "2026-10-24T10:24:42.016Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/05/61028b65913438a091ed3a2ec55bb50843e674b6ae14bccf196bbed652a2/pycryptodome-4.2.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:4b58c4fd5615a788e0df6793d578e565b19d75a348c0ba02d469b0c38bbf0332", size = 1684620, upload-time = "2026-10-24T10:23:50.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/73/0930fd56dd5eb856ecc33ec48845def52a1d25a9cd6b810a4c3ef3633cf4/pycryptodome-4.2.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42ab554efcc14928f1db42deac9c84989fd49e289a16aacbbee709fe2eb838cc", size = 1520423, upload-time = "2026-10-24T10:23:52.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ba/4b931de42881053a6d818b9ee70f6dde3dfe1cfcf78527d45db3f99d8b47/pycryptodome-4.2.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b4ed1a34d2d667ec165beb15313a759bcd8e704e2743f9942491c5a88a2d6ca5", size = 2296340, upload-time = "2026-10-24T10:23:54.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/63/68fc2a01d621c2ff389ea261e731e8f1abe6aba89992681bbcdb19fcca43/pycryptodome-4.2.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1482a7d9ceed7beadbb0853f83d4d44406a9f9fa8ee596d637618f095869e14", size = 2987589, upload-time = "2026-10-24T10:23:56.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/63/69e211459f4625c89bb47a7ba4527a6cc3276c9bbd79ae871a2d0b05c021/pycryptodome-4.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:dceadb6f478b0a687d129f1b58220e0587fb17a31b2502ae7a1a4234bc304ee1", size = 2322482, upload-time = "2026-10-24T10:23:58.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/21a4ed3ae5af153c57861d7f987c81fca5bf02d49b76f34c545f8eb6b95f/pycryptodome-4.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8217736d5ac9b7fef49d1d2b292d5af4e8300298ef4926742aaec49b2bf6505b", size = 3548352, upload-time = "2026-10-24T10:24:01.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a4/9a7e4e8896cfd8131978d31e39c930fb114f6a63751f39d2069b965dad45/pycryptodome-4.2.0-cp315-cp315t-win32.whl", hash = "sha256:b717e36cf14cffe463ff4e7d423a0c9fd6f83b40d207388e0f40dd0a3d4018bc", size = 1306386, upload-time = "2026-10-24T10:24:03.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/6c/e1a78019b7db83e2cfc1c98cea4a7a0afbb6b233514b9dfee2bad7443bd9/pycryptodome-4.2.0-cp315-cp315t-win_amd64.whl", hash = "sha256:585f0a792cdc7fb381ca0d1fac4e20f03fd99bb78cf2733eaab59f62c77ccf88", size = 1688319, upload-time = "2026-10-24T10:24:04.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/96/7cb051ba325141b33bc7e6ef6152499cf74b74606c5bb4c8c72411e8afdc/pycryptodome-4.2.0-cp315-cp315t-win_arm64.whl", hash = "sha256:831ae8ce070e990d4fcb913c49c7fb0181ea7ab2691fc58dcb9d41fdb5384d85", size = 1330914, upload-time = "2026-10-24T10:24:06.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/4c/954a9a1451f7bcc89d66db39bf6499ef5701e8ff35b6c898c659a7ec8546/pycryptodome-4.2.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e430168f767ecec60c1f850df4abbd0b9f94786b9cc8041a00e976f6d5190c6", size = 1680663, upload-time = "2026-10-24T10:24:08.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/84/23f8013dce07cbc8c8b9ed878972294230a1edc49a6b64d271c77c878c29/pycryptodome-4.2.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aea22120791b4534a27e39fc01d91265a249ba72dbd682a8a5c1adee475d5483", size = 1513323, upload-time = "2026-10-24T10:24:11.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/82/693940a8e4a20c3db91050480429daf39c818be3c7ec59f992ded4567ce6/pycryptodome-4.2.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6f30a457bd83a3074747d11bcb95375397a2e9b9a6b9179ab001e0cdd8ed0c5", size = 2292885, upload-time = "2026-10-24T10:24:13.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/88/33df6b1176b63aed427745696d88ed38f7877104cf0f6004ee9ad600f897/pycryptodome-4.2.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:76777686de3a354c41710727988c2284448d5db9bd82e5ddb9a9b6710d17d9df", size = 2976488, upload-time = "2026-10-24T10:24:16.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c6/c1c137d6e3284409ca5b2cfd7db3621167c316f9c314a96274af69a66fbb/pycryptodome-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c5bc41a418451616cd0ec3d765ba8539416d7429d7cd633ffb603a5a22355e70", size = 2313324, upload-time = "2026-10-24T10:24:18.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/b7752cffc8200040835ad24dc17c9de6376ac27612c8bf943766cb1b3c36/pycryptodome-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2c5b1e9058687daca3aaaff23eeb1c1853c8251986f0de7a950235e8bc0cc8b7", size = 3537773, upload-time = "2026-10-24T10:24:20.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/1b/34bec1416507cda23b5dfa33c0bca936fd4ac8ed4d3269803477b897649d/pycryptodome-4.2.0-cp39-abi3-win32.whl", hash = "sha256:bb7659f87d7aa69105e1be12764f9cf1908287eb424b242186f5ac3c192ed612", size = 1292974, upload-time = "2026-10-24T10:24:22.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5b/a4a31e6e1aabd5a26da77c7960e7fcce4fca9132e51868f1bba10bf1e932/pycryptodome-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:3282c9fd744a16594e51e1a9fbfe4d658b6b0aaa1f050926e6d591ac7494f78b", size = 1658684, upload-time = "2026-10-24T10:24:24.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/81/bddb0637fa85a03d00eea2f3514c5aa8d7b9c4921ef5e0c125b4ce662a8c/pycryptodome-4.2.0-cp39-abi3-win_arm64.whl", hash = "sha256:34ec11258967efec61d92a83a63a050ab7a7c31dc3806412f6334b8aef47e2d9", size = 1305591, upload-time = "2026-10-24T10:24:25.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a1/5460bbb750121f71abacaaf25f0cb1df67be7fe13c70cf6d7c2b8ad54fc0/pycryptodome-4.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7ea7dbf6b8ce0f0d2c0e17c1226c74d07c01499db7704797bdbb6cb9ec511ccb", size = 1472158, upload-time = "2026-10-24T10:24:27.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/65/27ac8fb15f0ffb70b72a0413291637de46d29b075804221703273c3ca6d1/pycryptodome-4.2.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0106af8684e1d73c93bc39a2f1cb4ae2f2636d1f80e6291caddcc37b4efc5dc1", size = 1247107, upload-time = "2026-10-24T10:24:29.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7d/6b3ed6cd8626007f6a6b13616b08150bc0bb936a0b84c310e59558ed5136/pycryptodome-4.2.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4deb46e0d0a540b1f047cb6f430249692da15c21edd75f5e6278314089fb859", size = 1426092, upload-time = "2026-10-24T10:24:31.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/45/5adf4dbb1eab1da6291d25f777d1d34f745c7f2993bf2d4d3b0d9abb87c6/pycryptodome-4.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e094873fe87ae4d546ef393cec7ea1e1cf8e0684f649612b19352afa67eb8bdb", size = 1662257, upload-time = "2026-10-24T10:24:33.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/88/fef93a9ecbda783a312b15a8823fa1a9a4ec3474685e756584af595457aa/pycryptodome-4.2.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:30f418cd1f4e776bde6c5df1fcd90ddfe74dfc8f679286a82f17ec1d4018cce0", size = 1467319, upload-time = "2026-10-24T10:24:35.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/65/cb53934c9f8d5c41dfcfaddb0d98103f4dd6670af1ffdbc2582bcdd99382/pycryptodome-4.2.0-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20cb4a489f7a5150a882901884b84ee0df77a47b9a6eb6e2dd8e98997b7b826f", size = 1247105, upload-time = "2026-10-24T10:24:36.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/38/5bba7fd24beb6bc7a82fdd155f2df39f51c8ad16143fc99d3a1ccc7c76b4/pycryptodome-4.2.0-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f104dc1a38aa1172a166ebe3716f2666f785bfa015b7ca13bb808c12c902e455", size = 1426092, upload-time = "2026-10-24T10:24:38.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/cf/e70a8cb1ab184b59b2d02dfd403965a0c6419571d8520463944bcb0ca8a0/pycryptodome-4.2.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:a7c6857077255f5f4e6c34e6325802ba1bb59960133c0a5e584d9371dbdf3dea", size = 1662214, upload-time = "2026-10-24T10:24:40.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3419,11 +3418,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "83.0.0"
|
||||
version = "84.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/eb/7e5f628ee2eb168fecbf8026de7856b2543d6d609cc85f086b9a91ac93d6/setuptools-84.1.0.tar.gz", hash = "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", size = 1171783, upload-time = "2026-10-19T20:11:43.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/02/b4bbb90f8f29e334898ff45c6d9b1fffaa6f378017052b3023a06262b5f7/setuptools-84.1.0-py3-none-any.whl", hash = "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9", size = 819380, upload-time = "2026-10-19T20:11:41.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3669,14 +3668,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-requests"
|
||||
version = "2.33.0.20260906"
|
||||
version = "2.33.0.20261006"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/18/4c2c0290953f8b3b9612adfcb07b57f144ade3ad32a76764fca42b77c5f3/types_requests-2.33.0.20260906.tar.gz", hash = "sha256:76ab8a0fb736744a0c3deee7aa57b2927e301f078d9e61f5391b3e92002416b9", size = 25263, upload-time = "2026-09-06T06:35:47.707Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/15/9b7e2e2e7c87d01366185b198b3febc6bc0c973f2bf21a62da4ab7d3495e/types_requests-2.33.0.20261006.tar.gz", hash = "sha256:0652999e9306aea345f40732d58fa49a7f6cade6a0d74d92119c5c8d82eddaf0", size = 25316, upload-time = "2026-10-06T08:15:57.782Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/4c/51ec821d22a45b4162fa3f3e9e94ea4c0f8c49e82363b10955baa5438391/types_requests-2.33.0.20260906-py3-none-any.whl", hash = "sha256:9f53622652bd921ead7a54d665be1b8d518165c8b51cc9d68d944cd6b6bfa8fb", size = 21461, upload-time = "2026-09-06T06:35:45.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/b82789207b3d360ce9f5a4372c790c52cc3dd928aeb7d4faeec652b651b2/types_requests-2.33.0.20261006-py3-none-any.whl", hash = "sha256:26cc8146505cab33cda9737991929e4144c559bebe05078ccc6998f27c4ca2c1", size = 21445, upload-time = "2026-10-06T08:15:56.658Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+2
-2
@@ -286,7 +286,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.18"
|
||||
version = "1.2.19"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -467,7 +467,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-sqlite"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
source = { editable = "../checkpoint-sqlite" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 0.4.12
|
||||
|
||||
### Removed
|
||||
|
||||
- **`PrioritySettings.fairness_weight`** — removed. The server stores only
|
||||
`priority` and `fairness_group` on the thread; it ignores a
|
||||
`fairness_weight` and logs a warning. Remove it from your callback.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`PrioritySettings.priority`** — now says -5 runs first and 5 runs last,
|
||||
matching how the server orders runs. 0.4.9 to 0.4.11 said the reverse, and
|
||||
their example gave premium users 3, which runs them after everyone at the
|
||||
default 0. Check any callback written from those docs.
|
||||
|
||||
## 0.4.11 and earlier
|
||||
|
||||
### Added
|
||||
|
||||
@@ -38,7 +53,7 @@
|
||||
and name the object in the `priority` section of `langgraph.json`. The
|
||||
server calls the function with a `PriorityContext` at the first run on a
|
||||
thread with no settings and stores the `PrioritySettings` it returns
|
||||
(`priority`, `fairness_group`, `fairness_weight`) on the thread. Requires
|
||||
(`priority`, `fairness_group`) on the thread. Requires
|
||||
`langgraph-api` 0.18.0 or later and a `langgraph-cli` that supports the
|
||||
section; older versions of either ignore it.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
||||
from langgraph_sdk.priority import Priority
|
||||
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
|
||||
|
||||
__version__ = "0.4.10"
|
||||
__version__ = "0.4.12"
|
||||
|
||||
__all__ = [
|
||||
"Auth",
|
||||
|
||||
@@ -14,9 +14,16 @@ import orjson
|
||||
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_orjson_default,
|
||||
_reconnect_delay,
|
||||
_run_check_headers,
|
||||
_run_url_from_stream_location,
|
||||
_validate_reconnect_location,
|
||||
)
|
||||
from langgraph_sdk.errors import _araise_for_status_typed
|
||||
from langgraph_sdk.errors import (
|
||||
InternalServerError,
|
||||
RateLimitError,
|
||||
_araise_for_status_typed,
|
||||
)
|
||||
from langgraph_sdk.schema import QueryParamTypes, StreamPart
|
||||
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
|
||||
|
||||
@@ -146,7 +153,8 @@ class HttpClient:
|
||||
on_response: Callable[[httpx.Response], None] | None = None,
|
||||
reconnect_limit: int = 5,
|
||||
) -> Any:
|
||||
"""Send a request that automatically reconnects to Location header."""
|
||||
"""Send a request that reconnects to its Location header if the response is
|
||||
cut off, with backoff between attempts."""
|
||||
request_headers, content = await _aencode_json(json)
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
@@ -176,13 +184,27 @@ class HttpClient:
|
||||
stacklevel=2,
|
||||
)
|
||||
await r.aclose()
|
||||
return await self.request_reconnect(
|
||||
loc,
|
||||
"GET",
|
||||
headers=headers,
|
||||
# don't pass on_response so it's only called once
|
||||
reconnect_limit=reconnect_limit - 1,
|
||||
)
|
||||
for attempt in range(reconnect_limit):
|
||||
await asyncio.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
|
||||
# A restart or a deploy looks like a refused connection, a
|
||||
# dropped body, a 429 or a 5xx; other errors won't go away.
|
||||
try:
|
||||
return await self.request_reconnect(
|
||||
loc,
|
||||
"GET",
|
||||
headers=headers,
|
||||
# don't pass on_response so it's only called once
|
||||
reconnect_limit=0,
|
||||
)
|
||||
except httpx.HTTPStatusError as err:
|
||||
status = err.response.status_code
|
||||
if attempt == reconnect_limit - 1 or (
|
||||
status != 429 and status < 500
|
||||
):
|
||||
raise
|
||||
except httpx.TransportError:
|
||||
if attempt == reconnect_limit - 1:
|
||||
raise
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
@@ -212,6 +234,7 @@ class HttpClient:
|
||||
reconnect_path: str | None = None
|
||||
reconnect_attempts = 0
|
||||
max_reconnect_attempts = 5
|
||||
run_ended = False
|
||||
|
||||
while True:
|
||||
current_headers = dict(
|
||||
@@ -225,70 +248,105 @@ class HttpClient:
|
||||
}
|
||||
if last_event_id:
|
||||
current_headers["Last-Event-ID"] = last_event_id
|
||||
if reconnect_path is not None and not any(
|
||||
key.lower() == "last-event-id" and value
|
||||
for key, value in current_headers.items()
|
||||
):
|
||||
warnings.warn(
|
||||
"Reconnecting without a Last-Event-ID, so events sent while "
|
||||
"disconnected are not replayed. Only runs created with "
|
||||
"stream_resumable=True send the event IDs a reconnect resumes "
|
||||
"from.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
current_method = method if reconnect_path is None else "GET"
|
||||
current_content = content if reconnect_path is None else None
|
||||
current_params = params if reconnect_path is None else None
|
||||
|
||||
retry = False
|
||||
async with self.client.stream(
|
||||
current_method,
|
||||
reconnect_path or path,
|
||||
headers=current_headers,
|
||||
content=current_content,
|
||||
params=current_params,
|
||||
) as res:
|
||||
if reconnect_path is None and on_response:
|
||||
on_response(res)
|
||||
# check status
|
||||
await _araise_for_status_typed(res)
|
||||
# check content type
|
||||
content_type = res.headers.get("content-type", "").partition(";")[0]
|
||||
if "text/event-stream" not in content_type:
|
||||
raise httpx.TransportError(
|
||||
"Expected response header Content-Type to contain 'text/event-stream', "
|
||||
f"got {content_type!r}"
|
||||
)
|
||||
reconnect_cause: httpx.HTTPError | None = None
|
||||
try:
|
||||
async with self.client.stream(
|
||||
current_method,
|
||||
reconnect_path or path,
|
||||
headers=current_headers,
|
||||
content=current_content,
|
||||
params=current_params,
|
||||
) as res:
|
||||
if reconnect_path is None and on_response:
|
||||
on_response(res)
|
||||
# check status
|
||||
await _araise_for_status_typed(res)
|
||||
# check content type
|
||||
content_type = res.headers.get("content-type", "").partition(";")[0]
|
||||
if "text/event-stream" not in content_type:
|
||||
raise httpx.TransportError(
|
||||
"Expected response header Content-Type to contain 'text/event-stream', "
|
||||
f"got {content_type!r}"
|
||||
)
|
||||
|
||||
reconnect_location = res.headers.get("location")
|
||||
if reconnect_location:
|
||||
_validate_reconnect_location(
|
||||
self.client.base_url, reconnect_location
|
||||
)
|
||||
reconnect_path = reconnect_location
|
||||
reconnect_location = res.headers.get("location")
|
||||
if reconnect_location:
|
||||
_validate_reconnect_location(
|
||||
self.client.base_url, reconnect_location
|
||||
)
|
||||
reconnect_path = reconnect_location
|
||||
|
||||
# parse SSE
|
||||
decoder = SSEDecoder()
|
||||
try:
|
||||
async for line in aiter_lines_raw(res):
|
||||
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
# parse SSE
|
||||
decoder = SSEDecoder(last_event_id=last_event_id)
|
||||
try:
|
||||
async for line in aiter_lines_raw(res):
|
||||
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
if decoder.last_event_id is not None:
|
||||
last_event_id = decoder.last_event_id
|
||||
if sse.event or decoder.has_data:
|
||||
# Progress since the last drop earns a fresh
|
||||
# set of reconnect attempts.
|
||||
reconnect_attempts = 0
|
||||
run_ended |= sse.event in ("end", "error")
|
||||
yield sse
|
||||
except httpx.HTTPError as err:
|
||||
# httpx.TransportError inherits from HTTPError, so transient
|
||||
# disconnects during streaming land here.
|
||||
if reconnect_path is None:
|
||||
raise
|
||||
reconnect_cause = err
|
||||
else:
|
||||
if sse := decoder.decode(b""):
|
||||
if decoder.last_event_id is not None:
|
||||
last_event_id = decoder.last_event_id
|
||||
if sse.event or sse.data is not None:
|
||||
if sse.event or decoder.has_data:
|
||||
# decoder.decode(b"") flushes the in-flight event and may
|
||||
# return an empty placeholder when there is no pending
|
||||
# message. Skip these no-op events so the stream doesn't
|
||||
# emit a trailing blank item after reconnects.
|
||||
run_ended |= sse.event in ("end", "error")
|
||||
yield sse
|
||||
except httpx.HTTPError:
|
||||
# httpx.TransportError inherits from HTTPError, so transient
|
||||
# disconnects during streaming land here.
|
||||
if reconnect_path is None:
|
||||
raise
|
||||
retry = True
|
||||
else:
|
||||
if sse := decoder.decode(b""):
|
||||
if decoder.last_event_id is not None:
|
||||
last_event_id = decoder.last_event_id
|
||||
if sse.event or sse.data is not None:
|
||||
# decoder.decode(b"") flushes the in-flight event and may
|
||||
# return an empty placeholder when there is no pending
|
||||
# message. Skip these no-op events so the stream doesn't
|
||||
# emit a trailing blank item after reconnects.
|
||||
yield sse
|
||||
if retry:
|
||||
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
|
||||
# A reconnect that can't open (connection refused, 429, 5xx or a
|
||||
# non-SSE body) usually means the server is restarting or
|
||||
# mid-deploy, so retry it like a drop. Before the server sends a
|
||||
# Location there is nothing to reconnect to, so raise.
|
||||
if reconnect_path is None:
|
||||
raise
|
||||
reconnect_cause = err
|
||||
# A run's stream closes cleanly both when the run ends and when a
|
||||
# proxy timeout or a server restart cuts it. Without an `end` or
|
||||
# `error` event the two look the same, so ask the run.
|
||||
if reconnect_cause is None and reconnect_path is not None and not run_ended:
|
||||
reconnect_cause = await _acheck_run(
|
||||
self.client, reconnect_path, reconnect_headers
|
||||
)
|
||||
if reconnect_cause is not None:
|
||||
reconnect_attempts += 1
|
||||
if reconnect_attempts > max_reconnect_attempts:
|
||||
raise httpx.TransportError(
|
||||
"Exceeded maximum SSE reconnection attempts"
|
||||
)
|
||||
) from reconnect_cause
|
||||
await asyncio.sleep(
|
||||
_reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0)
|
||||
)
|
||||
continue
|
||||
break
|
||||
|
||||
@@ -316,3 +374,28 @@ async def _adecode_json(r: httpx.Response) -> Any:
|
||||
if body
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
async def _acheck_run(
|
||||
client: httpx.AsyncClient, stream_location: str, headers: Mapping[str, str]
|
||||
) -> httpx.HTTPError | None:
|
||||
run_url = _run_url_from_stream_location(stream_location)
|
||||
if run_url is None:
|
||||
return None
|
||||
try:
|
||||
r = await client.get(run_url, headers=_run_check_headers(headers))
|
||||
if r.status_code == 429 or r.status_code >= 500:
|
||||
await _araise_for_status_typed(r)
|
||||
status = (await _adecode_json(r))["status"] if r.is_success else None
|
||||
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
|
||||
# The restart that closed the stream can fail the check too, so retry
|
||||
# it like a drop.
|
||||
return err
|
||||
except Exception:
|
||||
# Anything else must not turn a normal end of stream into an error.
|
||||
return None
|
||||
if status in ("pending", "running"):
|
||||
return httpx.TransportError(
|
||||
"Stream closed while the run was still pending or running"
|
||||
)
|
||||
return None
|
||||
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
@@ -25,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _reconnect_delay
|
||||
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
|
||||
from langgraph_sdk.stream.controller import _SeenEventIds
|
||||
from langgraph_sdk.stream.decoders import (
|
||||
@@ -1691,11 +1690,13 @@ class AsyncThreadStream:
|
||||
|
||||
async def _reconnect_sleep(self, attempt: int) -> None:
|
||||
"""Sleep with exponential backoff and jitter for reconnect attempt `attempt`."""
|
||||
base = self._shared_reconnect_backoff_base
|
||||
cap = self._shared_reconnect_backoff_cap
|
||||
delay = min(cap, base * (2**attempt))
|
||||
jitter = random.uniform(0, delay * 0.25)
|
||||
await asyncio.sleep(delay + jitter)
|
||||
await asyncio.sleep(
|
||||
_reconnect_delay(
|
||||
attempt,
|
||||
base=self._shared_reconnect_backoff_base,
|
||||
cap=self._shared_reconnect_backoff_cap,
|
||||
)
|
||||
)
|
||||
|
||||
async def _reconnect_shared_stream(self) -> bool:
|
||||
"""Attempt to reopen the shared stream after a post-ready transport drop.
|
||||
|
||||
@@ -21,6 +21,7 @@ from langgraph_sdk.schema import (
|
||||
ThreadSelectField,
|
||||
ThreadSortBy,
|
||||
ThreadState,
|
||||
ThreadStateSelectField,
|
||||
ThreadStatus,
|
||||
ThreadStreamMode,
|
||||
ThreadUpdateStateResponse,
|
||||
@@ -489,6 +490,7 @@ class ThreadsClient:
|
||||
checkpoint_id: str | None = None, # deprecated
|
||||
*,
|
||||
subgraphs: bool = False,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> ThreadState:
|
||||
@@ -499,6 +501,9 @@ class ThreadsClient:
|
||||
checkpoint: The checkpoint to get the state of.
|
||||
checkpoint_id: (deprecated) The checkpoint ID to get the state of.
|
||||
subgraphs: Include subgraphs states.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -591,16 +596,30 @@ class ThreadsClient:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fetch only the `messages` state key:
|
||||
|
||||
```python
|
||||
thread_state = await client.threads.get_state(
|
||||
thread_id="my_thread_id",
|
||||
select=["values.messages"],
|
||||
)
|
||||
```
|
||||
"""
|
||||
if checkpoint:
|
||||
payload: dict[str, Any] = {"checkpoint": checkpoint, "subgraphs": subgraphs}
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
elif checkpoint_id:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
@@ -609,7 +628,9 @@ class ThreadsClient:
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
@@ -692,6 +713,7 @@ class ThreadsClient:
|
||||
before: str | Checkpoint | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
checkpoint: Checkpoint | None = None,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[ThreadState]:
|
||||
@@ -703,6 +725,9 @@ class ThreadsClient:
|
||||
limit: The maximum number of states to return.
|
||||
before: Return states before this checkpoint.
|
||||
metadata: Filter states by metadata key-value pairs.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -729,6 +754,8 @@ class ThreadsClient:
|
||||
payload["metadata"] = metadata
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
|
||||
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import quote, urlparse, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -194,6 +195,34 @@ def _validate_reconnect_location(base_url: httpx.URL, location: str) -> str:
|
||||
return location
|
||||
|
||||
|
||||
_RUN_STREAM_PATH = re.compile(r"(.*/threads/[^/]+/runs/[^/]+)/stream")
|
||||
|
||||
|
||||
def _run_url_from_stream_location(location: str) -> str | None:
|
||||
url = urlsplit(location)
|
||||
if match := _RUN_STREAM_PATH.fullmatch(url.path):
|
||||
return urlunsplit((url.scheme, url.netloc, match[1], "", ""))
|
||||
return None
|
||||
|
||||
|
||||
def _run_check_headers(reconnect_headers: Mapping[str, str]) -> dict[str, str]:
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in reconnect_headers.items()
|
||||
if key.lower() not in ("accept", "last-event-id")
|
||||
}
|
||||
return {**headers, "Accept": "application/json"}
|
||||
|
||||
|
||||
def _reconnect_delay(attempt: int, *, base: float, cap: float) -> float:
|
||||
"""Seconds to wait before reconnect attempt `attempt` (0-based).
|
||||
|
||||
Exponential backoff capped at `cap`, plus up to 25% jitter.
|
||||
"""
|
||||
delay = min(cap, base * (2**attempt))
|
||||
return delay + random.uniform(0, delay * 0.25)
|
||||
|
||||
|
||||
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from typing import Any, cast
|
||||
@@ -13,9 +14,16 @@ import orjson
|
||||
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_orjson_default,
|
||||
_reconnect_delay,
|
||||
_run_check_headers,
|
||||
_run_url_from_stream_location,
|
||||
_validate_reconnect_location,
|
||||
)
|
||||
from langgraph_sdk.errors import _raise_for_status_typed
|
||||
from langgraph_sdk.errors import (
|
||||
InternalServerError,
|
||||
RateLimitError,
|
||||
_raise_for_status_typed,
|
||||
)
|
||||
from langgraph_sdk.schema import QueryParamTypes, StreamPart
|
||||
from langgraph_sdk.sse import SSEDecoder, iter_lines_raw
|
||||
|
||||
@@ -146,7 +154,8 @@ class SyncHttpClient:
|
||||
on_response: Callable[[httpx.Response], None] | None = None,
|
||||
reconnect_limit: int = 5,
|
||||
) -> Any:
|
||||
"""Send a request that automatically reconnects to Location header."""
|
||||
"""Send a request that reconnects to its Location header if the response is
|
||||
cut off, with backoff between attempts."""
|
||||
request_headers, content = _encode_json(json)
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
@@ -176,13 +185,27 @@ class SyncHttpClient:
|
||||
stacklevel=2,
|
||||
)
|
||||
r.close()
|
||||
return self.request_reconnect(
|
||||
loc,
|
||||
"GET",
|
||||
headers=headers,
|
||||
# don't pass on_response so it's only called once
|
||||
reconnect_limit=reconnect_limit - 1,
|
||||
)
|
||||
for attempt in range(reconnect_limit):
|
||||
time.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
|
||||
# A restart or a deploy looks like a refused connection, a
|
||||
# dropped body, a 429 or a 5xx; other errors won't go away.
|
||||
try:
|
||||
return self.request_reconnect(
|
||||
loc,
|
||||
"GET",
|
||||
headers=headers,
|
||||
# don't pass on_response so it's only called once
|
||||
reconnect_limit=0,
|
||||
)
|
||||
except httpx.HTTPStatusError as err:
|
||||
status = err.response.status_code
|
||||
if attempt == reconnect_limit - 1 or (
|
||||
status != 429 and status < 500
|
||||
):
|
||||
raise
|
||||
except httpx.TransportError:
|
||||
if attempt == reconnect_limit - 1:
|
||||
raise
|
||||
|
||||
def stream(
|
||||
self,
|
||||
@@ -214,6 +237,7 @@ class SyncHttpClient:
|
||||
reconnect_path: str | None = None
|
||||
reconnect_attempts = 0
|
||||
max_reconnect_attempts = 5
|
||||
run_ended = False
|
||||
|
||||
while True:
|
||||
current_headers = dict(
|
||||
@@ -227,67 +251,100 @@ class SyncHttpClient:
|
||||
}
|
||||
if last_event_id:
|
||||
current_headers["Last-Event-ID"] = last_event_id
|
||||
if reconnect_path is not None and not any(
|
||||
key.lower() == "last-event-id" and value
|
||||
for key, value in current_headers.items()
|
||||
):
|
||||
warnings.warn(
|
||||
"Reconnecting without a Last-Event-ID, so events sent while "
|
||||
"disconnected are not replayed. Only runs created with "
|
||||
"stream_resumable=True send the event IDs a reconnect resumes "
|
||||
"from.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
current_method = method if reconnect_path is None else "GET"
|
||||
current_content = content if reconnect_path is None else None
|
||||
current_params = params if reconnect_path is None else None
|
||||
|
||||
retry = False
|
||||
with self.client.stream(
|
||||
current_method,
|
||||
reconnect_path or path,
|
||||
headers=current_headers,
|
||||
content=current_content,
|
||||
params=current_params,
|
||||
) as res:
|
||||
if reconnect_path is None and on_response:
|
||||
on_response(res)
|
||||
# check status
|
||||
_raise_for_status_typed(res)
|
||||
# check content type
|
||||
content_type = res.headers.get("content-type", "").partition(";")[0]
|
||||
if "text/event-stream" not in content_type:
|
||||
raise httpx.TransportError(
|
||||
"Expected response header Content-Type to contain 'text/event-stream', "
|
||||
f"got {content_type!r}"
|
||||
)
|
||||
reconnect_cause: httpx.HTTPError | None = None
|
||||
try:
|
||||
with self.client.stream(
|
||||
current_method,
|
||||
reconnect_path or path,
|
||||
headers=current_headers,
|
||||
content=current_content,
|
||||
params=current_params,
|
||||
) as res:
|
||||
if reconnect_path is None and on_response:
|
||||
on_response(res)
|
||||
# check status
|
||||
_raise_for_status_typed(res)
|
||||
# check content type
|
||||
content_type = res.headers.get("content-type", "").partition(";")[0]
|
||||
if "text/event-stream" not in content_type:
|
||||
raise httpx.TransportError(
|
||||
"Expected response header Content-Type to contain 'text/event-stream', "
|
||||
f"got {content_type!r}"
|
||||
)
|
||||
|
||||
reconnect_location = res.headers.get("location")
|
||||
if reconnect_location:
|
||||
_validate_reconnect_location(
|
||||
self.client.base_url, reconnect_location
|
||||
)
|
||||
reconnect_path = reconnect_location
|
||||
reconnect_location = res.headers.get("location")
|
||||
if reconnect_location:
|
||||
_validate_reconnect_location(
|
||||
self.client.base_url, reconnect_location
|
||||
)
|
||||
reconnect_path = reconnect_location
|
||||
|
||||
decoder = SSEDecoder()
|
||||
try:
|
||||
for line in iter_lines_raw(res):
|
||||
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
decoder = SSEDecoder(last_event_id=last_event_id)
|
||||
try:
|
||||
for line in iter_lines_raw(res):
|
||||
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
if decoder.last_event_id is not None:
|
||||
last_event_id = decoder.last_event_id
|
||||
if sse.event or decoder.has_data:
|
||||
# Progress since the last drop earns a fresh
|
||||
# set of reconnect attempts.
|
||||
reconnect_attempts = 0
|
||||
run_ended |= sse.event in ("end", "error")
|
||||
yield sse
|
||||
except httpx.HTTPError as err:
|
||||
# httpx.TransportError inherits from HTTPError, so transient
|
||||
# disconnects during streaming land here.
|
||||
if reconnect_path is None:
|
||||
raise
|
||||
reconnect_cause = err
|
||||
else:
|
||||
if sse := decoder.decode(b""):
|
||||
if decoder.last_event_id is not None:
|
||||
last_event_id = decoder.last_event_id
|
||||
if sse.event or sse.data is not None:
|
||||
if sse.event or decoder.has_data:
|
||||
# See async stream implementation for rationale on
|
||||
# skipping empty flush events.
|
||||
run_ended |= sse.event in ("end", "error")
|
||||
yield sse
|
||||
except httpx.HTTPError:
|
||||
# httpx.TransportError inherits from HTTPError, so transient
|
||||
# disconnects during streaming land here.
|
||||
if reconnect_path is None:
|
||||
raise
|
||||
retry = True
|
||||
else:
|
||||
if sse := decoder.decode(b""):
|
||||
if decoder.last_event_id is not None:
|
||||
last_event_id = decoder.last_event_id
|
||||
if sse.event or sse.data is not None:
|
||||
# See async stream implementation for rationale on
|
||||
# skipping empty flush events.
|
||||
yield sse
|
||||
if retry:
|
||||
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
|
||||
# A reconnect that can't open (connection refused, 429, 5xx or a
|
||||
# non-SSE body) usually means the server is restarting or
|
||||
# mid-deploy, so retry it like a drop. Before the server sends a
|
||||
# Location there is nothing to reconnect to, so raise.
|
||||
if reconnect_path is None:
|
||||
raise
|
||||
reconnect_cause = err
|
||||
# A run's stream closes cleanly both when the run ends and when a
|
||||
# proxy timeout or a server restart cuts it. Without an `end` or
|
||||
# `error` event the two look the same, so ask the run.
|
||||
if reconnect_cause is None and reconnect_path is not None and not run_ended:
|
||||
reconnect_cause = _check_run(
|
||||
self.client, reconnect_path, reconnect_headers
|
||||
)
|
||||
if reconnect_cause is not None:
|
||||
reconnect_attempts += 1
|
||||
if reconnect_attempts > max_reconnect_attempts:
|
||||
raise httpx.TransportError(
|
||||
"Exceeded maximum SSE reconnection attempts"
|
||||
)
|
||||
) from reconnect_cause
|
||||
time.sleep(_reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0))
|
||||
continue
|
||||
break
|
||||
|
||||
@@ -309,3 +366,28 @@ def _encode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
|
||||
def _decode_json(r: httpx.Response) -> Any:
|
||||
body = r.read()
|
||||
return orjson.loads(body) if body else None
|
||||
|
||||
|
||||
def _check_run(
|
||||
client: httpx.Client, stream_location: str, headers: Mapping[str, str]
|
||||
) -> httpx.HTTPError | None:
|
||||
run_url = _run_url_from_stream_location(stream_location)
|
||||
if run_url is None:
|
||||
return None
|
||||
try:
|
||||
r = client.get(run_url, headers=_run_check_headers(headers))
|
||||
if r.status_code == 429 or r.status_code >= 500:
|
||||
_raise_for_status_typed(r)
|
||||
status = _decode_json(r)["status"] if r.is_success else None
|
||||
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
|
||||
# The restart that closed the stream can fail the check too, so retry
|
||||
# it like a drop.
|
||||
return err
|
||||
except Exception:
|
||||
# Anything else must not turn a normal end of stream into an error.
|
||||
return None
|
||||
if status in ("pending", "running"):
|
||||
return httpx.TransportError(
|
||||
"Stream closed while the run was still pending or running"
|
||||
)
|
||||
return None
|
||||
@@ -21,6 +21,7 @@ from langgraph_sdk.schema import (
|
||||
ThreadSelectField,
|
||||
ThreadSortBy,
|
||||
ThreadState,
|
||||
ThreadStateSelectField,
|
||||
ThreadStatus,
|
||||
ThreadStreamMode,
|
||||
ThreadUpdateStateResponse,
|
||||
@@ -480,6 +481,7 @@ class SyncThreadsClient:
|
||||
checkpoint_id: str | None = None, # deprecated
|
||||
*,
|
||||
subgraphs: bool = False,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> ThreadState:
|
||||
@@ -489,6 +491,9 @@ class SyncThreadsClient:
|
||||
thread_id: The ID of the thread to get the state of.
|
||||
checkpoint: The checkpoint to get the state of.
|
||||
subgraphs: Include subgraphs states.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -581,16 +586,29 @@ class SyncThreadsClient:
|
||||
}
|
||||
```
|
||||
|
||||
Fetch only the `messages` state key:
|
||||
|
||||
```python
|
||||
thread_state = client.threads.get_state(
|
||||
thread_id="my_thread_id",
|
||||
select=["values.messages"],
|
||||
)
|
||||
```
|
||||
"""
|
||||
if checkpoint:
|
||||
payload: dict[str, Any] = {"checkpoint": checkpoint, "subgraphs": subgraphs}
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
elif checkpoint_id:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
@@ -599,7 +617,9 @@ class SyncThreadsClient:
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
@@ -679,6 +699,7 @@ class SyncThreadsClient:
|
||||
before: str | Checkpoint | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
checkpoint: Checkpoint | None = None,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[ThreadState]:
|
||||
@@ -690,6 +711,9 @@ class SyncThreadsClient:
|
||||
limit: The maximum number of states to return.
|
||||
before: Return states before this checkpoint.
|
||||
metadata: Filter states by metadata key-value pairs.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -717,6 +741,8 @@ class SyncThreadsClient:
|
||||
payload["metadata"] = metadata
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
|
||||
@@ -811,7 +811,7 @@ class _On:
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
resources: str | Sequence[str],
|
||||
resources: str | Sequence[str] | None = None,
|
||||
actions: str | Sequence[str] | None = None,
|
||||
) -> Callable[[AHO], AHO]: ...
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ class Priority:
|
||||
@priority.assign
|
||||
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
|
||||
return {
|
||||
"priority": 3 if "premium" in ctx.permissions else 0,
|
||||
"priority": -3 if "premium" in ctx.permissions else 0,
|
||||
# Stored unencrypted, so an opaque ID rather than personal data.
|
||||
"fairness_group": ctx.user["org_id"],
|
||||
}
|
||||
@@ -134,7 +134,7 @@ class Priority:
|
||||
@priority.assign
|
||||
async def assign(ctx: PriorityContext) -> PrioritySettings | None:
|
||||
if ctx.cron_id is not None:
|
||||
return {"priority": -2}
|
||||
return {"priority": 2}
|
||||
return None
|
||||
```
|
||||
|
||||
|
||||
@@ -50,18 +50,20 @@ class PrioritySettings(typing.TypedDict, total=False):
|
||||
|
||||
Every key is optional: a missing key or a key set to `None` takes its
|
||||
default, and returning `None` instead of a dict gives every key its default.
|
||||
A value outside its range, or an empty `fairness_group`, is an error.
|
||||
A `priority` outside -5 to 5, or a `fairness_group` longer than 256
|
||||
characters, is an error.
|
||||
"""
|
||||
|
||||
priority: int | None
|
||||
"""An integer from -5 to 5, where 5 runs first and -5 runs last. Defaults to 0."""
|
||||
"""An integer from -5 to 5, where -5 runs first and 5 runs last. Defaults to 0."""
|
||||
|
||||
fairness_group: str | None
|
||||
"""The thread's fairness group, a non-empty string. Defaults to `"default"`.
|
||||
"""Currently disabled.
|
||||
The thread's fairness group, a string of at most 256 characters.
|
||||
|
||||
Defaults to `"default"`, as does an empty string. It has no effect on run
|
||||
order for now.
|
||||
|
||||
The server stores it unencrypted, even when the deployment uses custom
|
||||
encryption, so use an opaque ID rather than personal data.
|
||||
"""
|
||||
|
||||
fairness_weight: int | None
|
||||
"""The weight of the thread's fairness group, an integer from 1 to 100. Defaults to 1."""
|
||||
@@ -480,6 +480,21 @@ ThreadSelectField = Literal[
|
||||
"interrupts",
|
||||
]
|
||||
|
||||
ThreadStateSelectField = (
|
||||
Literal[
|
||||
"values",
|
||||
"next",
|
||||
"tasks",
|
||||
"checkpoint",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"parent_checkpoint",
|
||||
"interrupts",
|
||||
]
|
||||
| str
|
||||
)
|
||||
"""Also accepts `values.<key>` for a single state key, e.g. `values.messages`."""
|
||||
|
||||
RunSelectField = Literal[
|
||||
"run_id",
|
||||
"thread_id",
|
||||
|
||||
@@ -30,18 +30,14 @@ class BytesLineDecoder:
|
||||
# See https://docs.python.org/3/glossary.html#term-universal-newlines
|
||||
NEWLINE_CHARS = b"\n\r"
|
||||
|
||||
# We always push a trailing `\r` into the next decode iteration.
|
||||
if self.trailing_cr:
|
||||
text = b"\r" + text
|
||||
self.trailing_cr = False
|
||||
if text.endswith(b"\r"):
|
||||
self.trailing_cr = True
|
||||
text = text[:-1]
|
||||
if not text:
|
||||
return []
|
||||
if self.trailing_cr and text.startswith(b"\n"):
|
||||
text = text[1:]
|
||||
self.trailing_cr = text.endswith(b"\r")
|
||||
|
||||
if not text:
|
||||
# NOTE: the edge case input of empty text doesn't occur in practice,
|
||||
# because other httpx internals filter out this value
|
||||
return [] # pragma: no cover
|
||||
return []
|
||||
|
||||
trailing_newline = text[-1] in NEWLINE_CHARS
|
||||
lines = cast(list[BytesLike], text.splitlines())
|
||||
@@ -66,7 +62,7 @@ class BytesLineDecoder:
|
||||
return lines
|
||||
|
||||
def flush(self) -> list[BytesLike]:
|
||||
if not self.buffer and not self.trailing_cr:
|
||||
if not self.buffer:
|
||||
return []
|
||||
|
||||
lines: list[BytesLike] = [self.buffer]
|
||||
@@ -76,18 +72,25 @@ class BytesLineDecoder:
|
||||
|
||||
|
||||
class SSEDecoder:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, last_event_id: str | None = None) -> None:
|
||||
self._event = ""
|
||||
self._data = bytearray()
|
||||
self._last_event_id: str | None = None
|
||||
self._last_event_id = last_event_id
|
||||
self._retry: int | None = None
|
||||
self._at_stream_start = True
|
||||
self._has_data = False
|
||||
self._id_changed = False
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Whether the last decoded block contained a data field."""
|
||||
return self._has_data
|
||||
|
||||
@property
|
||||
def last_event_id(self) -> str | None:
|
||||
"""Return the last event identifier that was seen.
|
||||
|
||||
`None` until the stream sends an `id` field, and `""` after an empty
|
||||
`None` until an ID is supplied or received, and `""` after an empty
|
||||
one, which resets the reconnect cursor.
|
||||
"""
|
||||
|
||||
@@ -101,17 +104,26 @@ class SSEDecoder:
|
||||
line = line.removeprefix(b"\xef\xbb\xbf")
|
||||
|
||||
if not line:
|
||||
self._has_data = bool(self._data)
|
||||
if (
|
||||
not self._event
|
||||
and not self._data
|
||||
and self._last_event_id is None
|
||||
and not self._id_changed
|
||||
and self._retry is None
|
||||
):
|
||||
return None
|
||||
|
||||
body = self._data[:-1]
|
||||
data = None
|
||||
if body and not body.isspace():
|
||||
try:
|
||||
data = orjson.loads(body)
|
||||
except orjson.JSONDecodeError:
|
||||
data = orjson.loads(body.decode(errors="replace"))
|
||||
|
||||
sse = StreamPart(
|
||||
event=self._event,
|
||||
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
|
||||
data=data, # ty: ignore[invalid-argument-type]
|
||||
id=self._last_event_id or None,
|
||||
)
|
||||
|
||||
@@ -119,6 +131,7 @@ class SSEDecoder:
|
||||
self._event = ""
|
||||
self._data = bytearray()
|
||||
self._retry = None
|
||||
self._id_changed = False
|
||||
|
||||
return sse
|
||||
|
||||
@@ -131,16 +144,16 @@ class SSEDecoder:
|
||||
value = value[1:]
|
||||
|
||||
if fieldname == b"event":
|
||||
self._event = value.decode()
|
||||
self._event = value.decode(errors="replace")
|
||||
elif fieldname == b"data":
|
||||
if self._data:
|
||||
self._data.extend(b"\n")
|
||||
self._data.extend(value)
|
||||
self._data.extend(b"\n")
|
||||
elif fieldname == b"id":
|
||||
if b"\0" in value:
|
||||
pass
|
||||
else:
|
||||
self._last_event_id = value.decode()
|
||||
self._last_event_id = value.decode(errors="replace")
|
||||
self._id_changed = True
|
||||
elif fieldname == b"retry":
|
||||
with contextlib.suppress(TypeError, ValueError):
|
||||
self._retry = int(value)
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import random
|
||||
from collections import OrderedDict
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
@@ -23,6 +22,7 @@ from typing import Any
|
||||
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._shared.utilities import _reconnect_delay
|
||||
from langgraph_sdk.stream.transport import AsyncProtocolTransport, EventStreamHandle
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -275,11 +275,13 @@ class StreamController:
|
||||
|
||||
async def _reconnect_sleep(self, attempt: int) -> None:
|
||||
"""Sleep with exponential backoff and jitter for reconnect attempt *attempt*."""
|
||||
base = self._reconnect_backoff_base
|
||||
cap = self._reconnect_backoff_cap
|
||||
delay = min(cap, base * (2**attempt))
|
||||
jitter = random.uniform(0, delay * 0.25)
|
||||
await asyncio.sleep(delay + jitter)
|
||||
await asyncio.sleep(
|
||||
_reconnect_delay(
|
||||
attempt,
|
||||
base=self._reconnect_backoff_base,
|
||||
cap=self._reconnect_backoff_cap,
|
||||
)
|
||||
)
|
||||
|
||||
async def _reconnect_shared_stream(self) -> bool:
|
||||
"""Attempt to reopen the shared stream after a transport drop.
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -13,6 +12,7 @@ from typing import Any
|
||||
|
||||
from langchain_protocol import Event, SubscribeParams
|
||||
|
||||
from langgraph_sdk._shared.utilities import _reconnect_delay
|
||||
from langgraph_sdk.stream.errors import (
|
||||
is_non_retryable_stream_error,
|
||||
stream_error_frame,
|
||||
@@ -282,11 +282,13 @@ class SyncStreamController:
|
||||
|
||||
def _reconnect_sleep(self, attempt: int) -> None:
|
||||
"""Sleep with exponential backoff + jitter before a reconnect attempt."""
|
||||
base = self._reconnect_backoff_base
|
||||
cap = self._reconnect_backoff_cap
|
||||
delay = min(cap, base * (2**attempt))
|
||||
jitter = random.uniform(0, delay * 0.25)
|
||||
time.sleep(delay + jitter)
|
||||
time.sleep(
|
||||
_reconnect_delay(
|
||||
attempt,
|
||||
base=self._reconnect_backoff_base,
|
||||
cap=self._reconnect_backoff_cap,
|
||||
)
|
||||
)
|
||||
|
||||
def _reconnect_shared_stream(self) -> bool:
|
||||
"""Attempt to reopen the shared stream after a transport drop.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_sleep(monkeypatch):
|
||||
"""Record backoff sleeps without actually waiting."""
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_async_sleep(d):
|
||||
sleeps.append(d)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_async_sleep)
|
||||
monkeypatch.setattr(time, "sleep", lambda d: sleeps.append(d))
|
||||
return sleeps
|
||||
@@ -1,8 +1,49 @@
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[{}, {"actions": "read"}, {"resources": None}],
|
||||
)
|
||||
def test_global_handler_factory_accepts_optional_resources(kwargs) -> None:
|
||||
auth = Auth()
|
||||
tree = ast.parse(inspect.getsource(type(auth.on)))
|
||||
cls = tree.body[0]
|
||||
assert isinstance(cls, ast.ClassDef)
|
||||
factory_overload = next(
|
||||
node
|
||||
for node in cls.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "__call__"
|
||||
and node.decorator_list
|
||||
and any(arg.arg == "resources" for arg in node.args.kwonlyargs)
|
||||
)
|
||||
resources = next(
|
||||
arg for arg in factory_overload.args.kwonlyargs if arg.arg == "resources"
|
||||
)
|
||||
assert resources.annotation is not None
|
||||
assert ast.unparse(resources.annotation) == "str | Sequence[str] | None"
|
||||
default = factory_overload.args.kw_defaults[0]
|
||||
assert isinstance(default, ast.Constant) and default.value is None
|
||||
|
||||
@auth.on(**kwargs)
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
if kwargs.get("actions"):
|
||||
assert auth._handlers == {("*", "read"): [handler]}
|
||||
assert auth._global_handlers == []
|
||||
else:
|
||||
assert auth._global_handlers == [handler]
|
||||
assert auth._handlers == {}
|
||||
|
||||
|
||||
def test_handler_multiple_resources_and_actions() -> None:
|
||||
auth = Auth()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -13,6 +13,7 @@ from langgraph_sdk._async.runs import _wrap_stream_v2
|
||||
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
|
||||
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
|
||||
from langgraph_sdk.client import HttpClient, SyncHttpClient
|
||||
from langgraph_sdk.errors import InternalServerError, NotFoundError
|
||||
from langgraph_sdk.schema import (
|
||||
CheckpointPayload,
|
||||
CheckpointsStreamPart,
|
||||
@@ -130,6 +131,110 @@ def test_sse_decoder_strips_a_leading_bom():
|
||||
assert decoder.decode(b"") == StreamPart(event="values", data=1, id=None) # ty: ignore[invalid-argument-type]
|
||||
|
||||
|
||||
def test_control_blocks_do_not_repeat_after_a_cursor_update():
|
||||
decoder = SSEDecoder()
|
||||
decoder.decode(b"id: cursor")
|
||||
assert decoder.decode(b"") == StreamPart(event="", data=None, id="cursor") # ty: ignore[invalid-argument-type]
|
||||
decoder.decode(b": heartbeat")
|
||||
assert decoder.decode(b"") is None
|
||||
assert decoder.decode(b"") is None
|
||||
decoder.decode(b"id:")
|
||||
assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) # ty: ignore[invalid-argument-type]
|
||||
assert decoder.last_event_id == ""
|
||||
assert decoder.decode(b"") is None
|
||||
|
||||
|
||||
def test_cr_lines_are_emitted_without_waiting_for_another_chunk():
|
||||
decoder = BytesLineDecoder()
|
||||
assert decoder.decode(b"data: 1\r\r") == [b"data: 1", b""]
|
||||
assert decoder.decode(b"\n") == []
|
||||
assert decoder.decode(b"data: 2\r") == [b"data: 2"]
|
||||
assert decoder.decode(b"") == []
|
||||
assert decoder.decode(b"\n\r") == [b""]
|
||||
assert decoder.flush() == []
|
||||
|
||||
|
||||
def test_empty_data_lines_preserve_their_newlines():
|
||||
decoder = SSEDecoder()
|
||||
for line in (b"data", b"data: 1", b"data:"):
|
||||
decoder.decode(line)
|
||||
assert decoder._data == b"\n1\n\n"
|
||||
assert decoder.decode(b"") == StreamPart(event="", data=1, id=None) # ty: ignore[invalid-argument-type]
|
||||
decoder.decode(b"data:")
|
||||
decoder.decode(b"data:")
|
||||
assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) # ty: ignore[invalid-argument-type]
|
||||
assert decoder.has_data
|
||||
assert decoder.decode(b"") is None
|
||||
assert not decoder.has_data
|
||||
|
||||
|
||||
def test_malformed_utf8_is_replaced_in_fields_and_json_strings():
|
||||
decoder = SSEDecoder()
|
||||
for line in (b"event: \xff", b"id: \xff", b'data: "\xff"'):
|
||||
decoder.decode(line)
|
||||
assert decoder.decode(b"") == StreamPart(event="\ufffd", data="\ufffd", id="\ufffd") # ty: ignore[invalid-argument-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_null_and_empty_data_are_not_dropped(asynchronous: bool):
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
content=b"data: null\n\ndata:\n\n",
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
if asynchronous:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
parts = [part async for part in HttpClient(client).stream("/stream", "GET")]
|
||||
else:
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
parts = list(SyncHttpClient(client).stream("/stream", "GET"))
|
||||
assert parts == [StreamPart(event="", data=None), StreamPart(event="", data=None)] # ty: ignore[invalid-argument-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_cursor_is_inherited_after_reconnect(asynchronous: bool):
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if len(requests) == 1:
|
||||
stream_cls = AsyncListByteStream if asynchronous else ListByteStream
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream", "Location": "/reconnect"},
|
||||
stream=stream_cls(
|
||||
[b"id: cursor\nevent: values\ndata: 1\n\n"],
|
||||
httpx.RemoteProtocolError("incomplete chunked read"),
|
||||
),
|
||||
)
|
||||
assert (b"Last-Event-ID", b"cursor") in request.headers.raw
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
content=b"event: values\ndata: 2\n\n",
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
if asynchronous:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
parts = [part async for part in HttpClient(client).stream("/stream", "GET")]
|
||||
else:
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
parts = list(SyncHttpClient(client).stream("/stream", "GET"))
|
||||
assert [part.id for part in parts] == ["cursor", "cursor"]
|
||||
|
||||
|
||||
# --- HTTP client streaming ---
|
||||
|
||||
|
||||
@@ -176,6 +281,7 @@ def test_sync_http_client_stream_flushes_trailing_event():
|
||||
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_http_client_stream_recovers_after_disconnect():
|
||||
reconnect_path = "/reconnect"
|
||||
first_chunks = [
|
||||
@@ -241,6 +347,7 @@ def test_sync_http_client_stream_recovers_after_disconnect():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_http_client_stream_recovers_after_disconnect():
|
||||
reconnect_path = "/reconnect"
|
||||
first_chunks = [
|
||||
@@ -375,28 +482,33 @@ _CALLER_CURSORS = pytest.mark.parametrize(
|
||||
|
||||
@_EMPTY_IDS
|
||||
@_CALLER_CURSORS
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id(
|
||||
empty_id: bytes, headers: dict[str, str] | None
|
||||
):
|
||||
reconnect = _sync_reconnect(
|
||||
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Last-Event-ID"):
|
||||
reconnect = _sync_reconnect(
|
||||
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
|
||||
)
|
||||
|
||||
assert "last-event-id" not in reconnect.headers
|
||||
|
||||
|
||||
@_EMPTY_IDS
|
||||
@_CALLER_CURSORS
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id(
|
||||
empty_id: bytes, headers: dict[str, str] | None
|
||||
):
|
||||
reconnect = await _async_reconnect(
|
||||
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
|
||||
)
|
||||
with pytest.warns(UserWarning, match="Last-Event-ID"):
|
||||
reconnect = await _async_reconnect(
|
||||
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
|
||||
)
|
||||
|
||||
assert "last-event-id" not in reconnect.headers
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
|
||||
reconnect = _sync_reconnect(
|
||||
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
|
||||
@@ -405,6 +517,7 @@ def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
|
||||
assert reconnect.headers.get_list("last-event-id") == ["1"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
|
||||
reconnect = await _async_reconnect(
|
||||
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
|
||||
@@ -413,12 +526,14 @@ async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
|
||||
assert reconnect.headers.get_list("last-event-id") == ["1"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
|
||||
reconnect = _sync_reconnect([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"})
|
||||
|
||||
assert reconnect.headers.get_list("last-event-id") == ["5"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
|
||||
reconnect = await _async_reconnect(
|
||||
[b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}
|
||||
@@ -427,6 +542,563 @@ async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id
|
||||
assert reconnect.headers.get_list("last-event-id") == ["5"]
|
||||
|
||||
|
||||
# A reconnect that sends no Last-Event-ID can't replay what was sent while it was
|
||||
# disconnected, so it warns.
|
||||
_RECONNECT_CURSORS = pytest.mark.parametrize(
|
||||
("chunks", "headers", "warns"),
|
||||
[
|
||||
([b"event: values\ndata: 1\n\n"], None, True),
|
||||
([b"id: 1\nevent: values\ndata: 1\n\n"], None, False),
|
||||
([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}, False),
|
||||
],
|
||||
ids=["no-cursor", "stream-cursor", "caller-cursor"],
|
||||
)
|
||||
|
||||
|
||||
@_RECONNECT_CURSORS
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_reconnect_warns_only_without_a_cursor(
|
||||
chunks: list[bytes],
|
||||
headers: dict[str, str] | None,
|
||||
warns: bool,
|
||||
recwarn: pytest.WarningsRecorder,
|
||||
):
|
||||
_sync_reconnect(chunks, headers)
|
||||
|
||||
assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
|
||||
|
||||
|
||||
@_RECONNECT_CURSORS
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_reconnect_warns_only_without_a_cursor(
|
||||
chunks: list[bytes],
|
||||
headers: dict[str, str] | None,
|
||||
warns: bool,
|
||||
recwarn: pytest.WarningsRecorder,
|
||||
):
|
||||
await _async_reconnect(chunks, headers)
|
||||
|
||||
assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
|
||||
|
||||
|
||||
# --- reconnects survive a server restart or a deploy ---
|
||||
|
||||
SSE = {"Content-Type": "text/event-stream"}
|
||||
DROP = httpx.RemoteProtocolError("incomplete chunked read")
|
||||
|
||||
|
||||
def _event(i: int) -> bytes:
|
||||
return f"id: {i}\nevent: values\ndata: {i}\n\n".encode()
|
||||
|
||||
|
||||
def _scripted(steps: list[Callable[[httpx.Request], httpx.Response]]):
|
||||
"""Handler that plays one step per request and records each request."""
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
if len(seen) > len(steps):
|
||||
raise AssertionError("unexpected request")
|
||||
return steps[len(seen) - 1](request)
|
||||
|
||||
return handler, seen
|
||||
|
||||
|
||||
def _ok(stream_cls, chunks, *, drop=False, location=None):
|
||||
headers = dict(SSE)
|
||||
if location:
|
||||
headers["Location"] = location
|
||||
|
||||
def step(_request):
|
||||
return httpx.Response(
|
||||
200, headers=headers, stream=stream_cls(chunks, DROP if drop else None)
|
||||
)
|
||||
|
||||
return step
|
||||
|
||||
|
||||
def _status(code):
|
||||
return lambda request: httpx.Response(
|
||||
code, json={"detail": "upstream"}, request=request
|
||||
)
|
||||
|
||||
|
||||
def _connect_error(request):
|
||||
raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
|
||||
|
||||
|
||||
def _not_sse(request):
|
||||
return httpx.Response(
|
||||
200, headers={"Content-Type": "text/html"}, text="<html>", request=request
|
||||
)
|
||||
|
||||
|
||||
def _transient_steps(stream_cls, failure):
|
||||
"""POST drops after event 1; first reconnect fails; second succeeds."""
|
||||
return [
|
||||
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
|
||||
failure,
|
||||
_ok(stream_cls, [_event(2), b"event: end\ndata: null\n\n"]),
|
||||
]
|
||||
|
||||
|
||||
def _long_stream_steps(stream_cls, drops):
|
||||
"""POST + `drops` reconnects, each delivering one event then dropping."""
|
||||
steps = [_ok(stream_cls, [_event(0)], drop=True, location="/reconnect")]
|
||||
for i in range(1, drops):
|
||||
steps.append(_ok(stream_cls, [_event(i)], drop=True))
|
||||
steps.append(_ok(stream_cls, [_event(drops), b"event: end\ndata: null\n\n"]))
|
||||
return steps
|
||||
|
||||
|
||||
def _run_sync(steps, headers=None):
|
||||
handler, seen = _scripted(steps)
|
||||
with httpx.Client(
|
||||
transport=httpx.MockTransport(handler), base_url="https://example.com"
|
||||
) as client:
|
||||
parts = list(
|
||||
SyncHttpClient(client).stream("/stream", "POST", json={}, headers=headers)
|
||||
)
|
||||
return parts, seen
|
||||
|
||||
|
||||
async def _run_async(steps, headers=None):
|
||||
sync_handler, seen = _scripted(steps)
|
||||
|
||||
async def handler(request):
|
||||
return sync_handler(request)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler), base_url="https://example.com"
|
||||
) as client:
|
||||
parts = [
|
||||
p
|
||||
async for p in HttpClient(client).stream(
|
||||
"/stream", "POST", json={}, headers=headers
|
||||
)
|
||||
]
|
||||
return parts, seen
|
||||
|
||||
|
||||
FAILURES = {
|
||||
"429": _status(429),
|
||||
"502": _status(502),
|
||||
"503": _status(503),
|
||||
"connect_error": _connect_error,
|
||||
"not_sse": _not_sse,
|
||||
}
|
||||
|
||||
|
||||
# A reconnect that hits a 429 / 502 / 503 / connect error / non-SSE body, then
|
||||
# succeeds, should recover.
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("failure", FAILURES.values(), ids=FAILURES.keys())
|
||||
def test_sync_reconnect_recovers_from_transient_failure(failure):
|
||||
parts, seen = _run_sync(_transient_steps(ListByteStream, failure))
|
||||
assert [p.event for p in parts] == ["values", "values", "end"]
|
||||
assert len(seen) == 3
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("failure", FAILURES.values(), ids=FAILURES.keys())
|
||||
async def test_async_reconnect_recovers_from_transient_failure(failure):
|
||||
parts, seen = await _run_async(_transient_steps(AsyncListByteStream, failure))
|
||||
assert [p.event for p in parts] == ["values", "values", "end"]
|
||||
assert len(seen) == 3
|
||||
|
||||
|
||||
# Any other 4xx on a reconnect won't go away by retrying, so it raises.
|
||||
|
||||
|
||||
def _client_error_steps(stream_cls):
|
||||
return [
|
||||
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
|
||||
_status(404),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_reconnect_does_not_retry_a_client_error():
|
||||
with pytest.raises(NotFoundError):
|
||||
_run_sync(_client_error_steps(ListByteStream))
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_reconnect_does_not_retry_a_client_error():
|
||||
with pytest.raises(NotFoundError):
|
||||
await _run_async(_client_error_steps(AsyncListByteStream))
|
||||
|
||||
|
||||
# The first request isn't a reconnect, so it raises even when it names a Location.
|
||||
|
||||
|
||||
def _first_request_503(request):
|
||||
return httpx.Response(
|
||||
503,
|
||||
headers={"Location": "/reconnect"},
|
||||
json={"detail": "upstream"},
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_first_request_failure_is_not_retried():
|
||||
with pytest.raises(InternalServerError):
|
||||
_run_sync([_first_request_503])
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_first_request_failure_is_not_retried():
|
||||
with pytest.raises(InternalServerError):
|
||||
await _run_async([_first_request_503])
|
||||
|
||||
|
||||
# A server that stays down gets five reconnects, backing off 1, 2, 4, 5 and 5s
|
||||
# (each plus up to 25% jitter), so about 20s, before the stream gives up. The
|
||||
# error it gives up with is caused by the last failure, so callers see why.
|
||||
|
||||
GIVE_UP = "Exceeded maximum SSE reconnection attempts"
|
||||
|
||||
|
||||
def _server_down_steps(stream_cls):
|
||||
return [
|
||||
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
|
||||
*[_status(502)] * 5,
|
||||
]
|
||||
|
||||
|
||||
def _assert_backed_off_for_about_20s(sleeps: list[float]) -> None:
|
||||
assert len(sleeps) == 5
|
||||
for slept, delay in zip(sleeps, [1, 2, 4, 5, 5], strict=True):
|
||||
assert delay <= slept <= delay * 1.25
|
||||
|
||||
|
||||
def test_sync_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
|
||||
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
|
||||
_run_sync(_server_down_steps(ListByteStream))
|
||||
assert isinstance(exc_info.value.__cause__, InternalServerError)
|
||||
_assert_backed_off_for_about_20s(no_sleep)
|
||||
|
||||
|
||||
async def test_async_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
|
||||
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
|
||||
await _run_async(_server_down_steps(AsyncListByteStream))
|
||||
assert isinstance(exc_info.value.__cause__, InternalServerError)
|
||||
_assert_backed_off_for_about_20s(no_sleep)
|
||||
|
||||
|
||||
# A long stream that drops 6 times, each reconnect succeeding and making
|
||||
# progress, should not run out of attempts.
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_long_stream_survives_many_spaced_out_drops():
|
||||
parts, _ = _run_sync(_long_stream_steps(ListByteStream, drops=6))
|
||||
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_long_stream_survives_many_spaced_out_drops():
|
||||
parts, _ = await _run_async(_long_stream_steps(AsyncListByteStream, drops=6))
|
||||
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
|
||||
|
||||
|
||||
# Each reconnect resumes after the newest event the stream got, so a reconnect
|
||||
# loop on a resumable run replays only what it missed. A failed reconnect keeps
|
||||
# the cursor for the next one.
|
||||
RECONNECT_LOOPS = {
|
||||
"progress_between_drops": (
|
||||
lambda stream_cls: _long_stream_steps(stream_cls, drops=3),
|
||||
[None, "0", "1", "2"],
|
||||
),
|
||||
"failed_reconnect": (
|
||||
lambda stream_cls: _transient_steps(stream_cls, _status(502)),
|
||||
[None, "1", "1"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize(
|
||||
("steps", "cursors"), RECONNECT_LOOPS.values(), ids=RECONNECT_LOOPS.keys()
|
||||
)
|
||||
def test_sync_reconnect_loop_resumes_after_the_newest_event(steps, cursors):
|
||||
_, seen = _run_sync(steps(ListByteStream))
|
||||
assert [r.headers.get("last-event-id") for r in seen] == cursors
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize(
|
||||
("steps", "cursors"), RECONNECT_LOOPS.values(), ids=RECONNECT_LOOPS.keys()
|
||||
)
|
||||
async def test_async_reconnect_loop_resumes_after_the_newest_event(steps, cursors):
|
||||
_, seen = await _run_async(steps(AsyncListByteStream))
|
||||
assert [r.headers.get("last-event-id") for r in seen] == cursors
|
||||
|
||||
|
||||
# Reconnects should back off rather than hammer the server.
|
||||
|
||||
|
||||
def test_sync_reconnect_backs_off(no_sleep):
|
||||
_run_sync(_long_stream_steps(ListByteStream, drops=2))
|
||||
assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
|
||||
|
||||
|
||||
async def test_async_reconnect_backs_off(no_sleep):
|
||||
await _run_async(_long_stream_steps(AsyncListByteStream, drops=2))
|
||||
assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
|
||||
|
||||
|
||||
# --- a run stream that closes cleanly before the run ends ---
|
||||
|
||||
RUN_STREAM = "/threads/t1/runs/r1/stream"
|
||||
RUN = "/threads/t1/runs/r1"
|
||||
END = b"event: end\ndata: null\n\n"
|
||||
|
||||
Step = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
def _run_status(status: str) -> Step:
|
||||
return lambda request: httpx.Response(200, json={"status": status}, request=request)
|
||||
|
||||
|
||||
def _clean_close_steps(stream_cls: type, closes: int, check: Step) -> list[Step]:
|
||||
steps = [_ok(stream_cls, [_event(0)], location=RUN_STREAM)]
|
||||
for i in range(1, closes + 1):
|
||||
steps += [check, _ok(stream_cls, [_event(i)])]
|
||||
return [*steps, _run_status("success")]
|
||||
|
||||
|
||||
def _stalled_steps(stream_cls: type) -> list[Step]:
|
||||
return [
|
||||
_ok(stream_cls, [_event(1)], location=RUN_STREAM),
|
||||
*[_run_status("running"), _ok(stream_cls, [])] * 5,
|
||||
_run_status("running"),
|
||||
]
|
||||
|
||||
|
||||
def _requests(seen: list[httpx.Request]) -> list[tuple[str, str, str | None]]:
|
||||
return [(r.method, r.url.path, r.headers.get("last-event-id")) for r in seen]
|
||||
|
||||
|
||||
RESUMED = [
|
||||
("POST", "/stream", None),
|
||||
("GET", RUN, None),
|
||||
("GET", RUN_STREAM, "0"),
|
||||
("GET", RUN, None),
|
||||
]
|
||||
|
||||
|
||||
CHECKS_THAT_RECONNECT = {
|
||||
"pending": _run_status("pending"),
|
||||
"running": _run_status("running"),
|
||||
"429": _status(429),
|
||||
"503": _status(503),
|
||||
"connect_error": _connect_error,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"check", CHECKS_THAT_RECONNECT.values(), ids=CHECKS_THAT_RECONNECT.keys()
|
||||
)
|
||||
def test_sync_clean_close_reconnects_after_check(check: Step, no_sleep: list[float]):
|
||||
parts, seen = _run_sync(_clean_close_steps(ListByteStream, 1, check))
|
||||
assert [p.data for p in parts] == [0, 1]
|
||||
assert _requests(seen) == RESUMED
|
||||
assert len(no_sleep) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"check", CHECKS_THAT_RECONNECT.values(), ids=CHECKS_THAT_RECONNECT.keys()
|
||||
)
|
||||
async def test_async_clean_close_reconnects_after_check(
|
||||
check: Step, no_sleep: list[float]
|
||||
):
|
||||
parts, seen = await _run_async(_clean_close_steps(AsyncListByteStream, 1, check))
|
||||
assert [p.data for p in parts] == [0, 1]
|
||||
assert _requests(seen) == RESUMED
|
||||
assert len(no_sleep) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_clean_closes_with_progress_do_not_run_out_of_attempts():
|
||||
parts, _ = _run_sync(_clean_close_steps(ListByteStream, 6, _run_status("running")))
|
||||
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_clean_closes_with_progress_do_not_run_out_of_attempts():
|
||||
parts, _ = await _run_async(
|
||||
_clean_close_steps(AsyncListByteStream, 6, _run_status("running"))
|
||||
)
|
||||
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6]
|
||||
|
||||
|
||||
CHECKS_THAT_END = {
|
||||
"success": _run_status("success"),
|
||||
"error": _run_status("error"),
|
||||
"timeout": _run_status("timeout"),
|
||||
"interrupted": _run_status("interrupted"),
|
||||
"403": _status(403),
|
||||
"404": _status(404),
|
||||
"not_json": _not_sse,
|
||||
"json_without_status": lambda request: httpx.Response(
|
||||
200, json={}, request=request
|
||||
),
|
||||
}
|
||||
CHECKED = [("POST", "/stream", None), ("GET", RUN, None)]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("check", CHECKS_THAT_END.values(), ids=CHECKS_THAT_END.keys())
|
||||
def test_sync_clean_close_ends_the_stream_after_check(check: Step):
|
||||
parts, seen = _run_sync(
|
||||
[_ok(ListByteStream, [_event(1)], location=RUN_STREAM), check]
|
||||
)
|
||||
assert [p.data for p in parts] == [1]
|
||||
assert _requests(seen) == CHECKED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("check", CHECKS_THAT_END.values(), ids=CHECKS_THAT_END.keys())
|
||||
async def test_async_clean_close_ends_the_stream_after_check(check: Step):
|
||||
parts, seen = await _run_async(
|
||||
[_ok(AsyncListByteStream, [_event(1)], location=RUN_STREAM), check]
|
||||
)
|
||||
assert [p.data for p in parts] == [1]
|
||||
assert _requests(seen) == CHECKED
|
||||
|
||||
|
||||
NO_CHECK = {
|
||||
"end": lambda s: [_ok(s, [_event(1), END], location=RUN_STREAM)],
|
||||
"error": lambda s: [
|
||||
_ok(s, [_event(1), b"event: error\ndata: {}\n\n"], location=RUN_STREAM)
|
||||
],
|
||||
"unterminated_end": lambda s: [
|
||||
_ok(s, [_event(1), b"event: end\ndata: null\n"], location=RUN_STREAM)
|
||||
],
|
||||
"end_on_an_earlier_connection": lambda s: [
|
||||
_ok(s, [_event(1), END], drop=True, location=RUN_STREAM),
|
||||
_ok(s, []),
|
||||
],
|
||||
"thread_stream": lambda s: [_ok(s, [_event(1)], location="/threads/t1/stream")],
|
||||
"threadless_run_location": lambda s: [
|
||||
_ok(s, [_event(1)], location="/runs/r1/stream")
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("steps", NO_CHECK.values(), ids=NO_CHECK.keys())
|
||||
def test_sync_stream_ends_without_a_run_check(steps: Callable[[type], list[Step]]):
|
||||
_, seen = _run_sync(steps(ListByteStream))
|
||||
assert [r.url.path for r in seen if not r.url.path.endswith("/stream")] == []
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("steps", NO_CHECK.values(), ids=NO_CHECK.keys())
|
||||
async def test_async_stream_ends_without_a_run_check(
|
||||
steps: Callable[[type], list[Step]],
|
||||
):
|
||||
_, seen = await _run_async(steps(AsyncListByteStream))
|
||||
assert [r.url.path for r in seen if not r.url.path.endswith("/stream")] == []
|
||||
|
||||
|
||||
def test_sync_clean_closes_without_progress_give_up_after_five_attempts(
|
||||
no_sleep: list[float],
|
||||
):
|
||||
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
|
||||
_run_sync(_stalled_steps(ListByteStream))
|
||||
assert "still pending or running" in str(exc_info.value.__cause__)
|
||||
_assert_backed_off_for_about_20s(no_sleep)
|
||||
|
||||
|
||||
async def test_async_clean_closes_without_progress_give_up_after_five_attempts(
|
||||
no_sleep: list[float],
|
||||
):
|
||||
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
|
||||
await _run_async(_stalled_steps(AsyncListByteStream))
|
||||
assert "still pending or running" in str(exc_info.value.__cause__)
|
||||
_assert_backed_off_for_about_20s(no_sleep)
|
||||
|
||||
|
||||
CHECK_URLS = {
|
||||
"path": ("/threads/t1/runs/r1/stream", "https://example.com/threads/t1/runs/r1"),
|
||||
"prefix_and_query": (
|
||||
"/api/threads/t1/runs/r1/stream?stream_mode=values",
|
||||
"https://example.com/api/threads/t1/runs/r1",
|
||||
),
|
||||
"absolute": (
|
||||
"https://example.com/threads/t1/runs/r1/stream",
|
||||
"https://example.com/threads/t1/runs/r1",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize(
|
||||
("location", "url"), CHECK_URLS.values(), ids=CHECK_URLS.keys()
|
||||
)
|
||||
def test_sync_run_check_gets_the_run_behind_the_stream_location(
|
||||
location: str, url: str
|
||||
):
|
||||
_, seen = _run_sync(
|
||||
[_ok(ListByteStream, [_event(1)], location=location), _run_status("success")]
|
||||
)
|
||||
assert str(seen[-1].url) == url
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize(
|
||||
("location", "url"), CHECK_URLS.values(), ids=CHECK_URLS.keys()
|
||||
)
|
||||
async def test_async_run_check_gets_the_run_behind_the_stream_location(
|
||||
location: str, url: str
|
||||
):
|
||||
_, seen = await _run_async(
|
||||
[
|
||||
_ok(AsyncListByteStream, [_event(1)], location=location),
|
||||
_run_status("success"),
|
||||
]
|
||||
)
|
||||
assert str(seen[-1].url) == url
|
||||
|
||||
|
||||
CALLER_HEADERS = {"Authorization": "Bearer token", "Last-Event-ID": "0"}
|
||||
BODY_AND_CURSOR_HEADERS = {"content-type", "content-length", "last-event-id"}
|
||||
|
||||
|
||||
def _assert_run_check_headers(seen: list[httpx.Request]) -> None:
|
||||
check = seen[-1]
|
||||
assert check.url.path == RUN
|
||||
assert check.headers["authorization"] == "Bearer token"
|
||||
assert check.headers["accept"] == "application/json"
|
||||
assert check.headers.keys() & BODY_AND_CURSOR_HEADERS == set()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_run_check_sends_caller_headers_and_accepts_json_without_body_or_cursor():
|
||||
_, seen = _run_sync(
|
||||
[_ok(ListByteStream, [_event(1)], location=RUN_STREAM), _run_status("success")],
|
||||
headers=CALLER_HEADERS,
|
||||
)
|
||||
_assert_run_check_headers(seen)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_run_check_sends_caller_headers_and_accepts_json_without_body_or_cursor():
|
||||
_, seen = await _run_async(
|
||||
[
|
||||
_ok(AsyncListByteStream, [_event(1)], location=RUN_STREAM),
|
||||
_run_status("success"),
|
||||
],
|
||||
headers=CALLER_HEADERS,
|
||||
)
|
||||
_assert_run_check_headers(seen)
|
||||
|
||||
|
||||
# --- _sse_to_v2_dict conversion ---
|
||||
|
||||
|
||||
|
||||
@@ -120,9 +120,5 @@ def test_context_is_frozen():
|
||||
|
||||
|
||||
def test_every_settings_key_is_optional():
|
||||
assert PrioritySettings.__optional_keys__ == {
|
||||
"priority",
|
||||
"fairness_group",
|
||||
"fairness_weight",
|
||||
}
|
||||
assert PrioritySettings.__optional_keys__ == {"priority", "fairness_group"}
|
||||
assert PrioritySettings.__required_keys__ == frozenset()
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -24,7 +25,16 @@ class _BodyDropsMidRead(httpx.SyncByteStream, httpx.AsyncByteStream):
|
||||
raise httpx.ReadError("connection dropped")
|
||||
|
||||
|
||||
def _wait_handler(requests: list[httpx.Request]):
|
||||
def _done(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"done": True})
|
||||
|
||||
|
||||
def _wait_handler(
|
||||
requests: list[httpx.Request],
|
||||
reconnects: Sequence[Callable[[httpx.Request], httpx.Response]] = (_done,),
|
||||
):
|
||||
"""POST drops mid-body; the reconnects answer from `reconnects` in order."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if request.method == "POST":
|
||||
@@ -33,7 +43,7 @@ def _wait_handler(requests: list[httpx.Request]):
|
||||
headers={"Location": "/threads/t1/runs/r1/join"},
|
||||
stream=_BodyDropsMidRead(),
|
||||
)
|
||||
return httpx.Response(200, json={"done": True})
|
||||
return reconnects[len(requests) - 2](request)
|
||||
|
||||
return handler
|
||||
|
||||
@@ -46,6 +56,7 @@ def _assert_bodyless_get(request: httpx.Request) -> None:
|
||||
assert request.read() == b""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
def test_sync_wait_reconnects_with_a_bodyless_get():
|
||||
requests: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(_wait_handler(requests))
|
||||
@@ -58,6 +69,7 @@ def test_sync_wait_reconnects_with_a_bodyless_get():
|
||||
_assert_bodyless_get(requests[1])
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
async def test_async_wait_reconnects_with_a_bodyless_get():
|
||||
requests: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(_wait_handler(requests))
|
||||
@@ -72,6 +84,89 @@ async def test_async_wait_reconnects_with_a_bodyless_get():
|
||||
_assert_bodyless_get(requests[1])
|
||||
|
||||
|
||||
def _connect_error(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
|
||||
|
||||
|
||||
async def _wait(
|
||||
handler: Callable[[httpx.Request], httpx.Response], *, asynchronous: bool
|
||||
) -> Any:
|
||||
transport = httpx.MockTransport(handler)
|
||||
if asynchronous:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
return await RunsClient(HttpClient(client)).wait("t1", "agent", input={})
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
return SyncRunsClient(SyncHttpClient(client)).wait("t1", "agent", input={})
|
||||
|
||||
|
||||
# What a server restart or a deploy looks like to the reconnect GET: each one is
|
||||
# retried after a backoff.
|
||||
RECONNECT_FAILURES = {
|
||||
"429": lambda request: httpx.Response(429, request=request),
|
||||
"503": lambda request: httpx.Response(503, request=request),
|
||||
"connect_error": _connect_error,
|
||||
"body_drops": lambda _request: httpx.Response(
|
||||
200,
|
||||
headers={"Location": "/threads/t1/runs/r1/join"},
|
||||
stream=_BodyDropsMidRead(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"failure", RECONNECT_FAILURES.values(), ids=RECONNECT_FAILURES.keys()
|
||||
)
|
||||
async def test_wait_retries_a_failed_reconnect_after_a_backoff(
|
||||
failure, asynchronous, no_sleep
|
||||
):
|
||||
requests: list[httpx.Request] = []
|
||||
with pytest.warns(UserWarning, match="attempting reconnect"):
|
||||
result = await _wait(
|
||||
_wait_handler(requests, [failure, _done]), asynchronous=asynchronous
|
||||
)
|
||||
|
||||
assert result == {"done": True}
|
||||
assert len(requests) == 3
|
||||
assert len(no_sleep) == 2 and min(no_sleep) >= 1, no_sleep
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_wait_raises_after_five_failed_reconnects(asynchronous):
|
||||
requests: list[httpx.Request] = []
|
||||
with (
|
||||
pytest.warns(UserWarning, match="attempting reconnect"),
|
||||
pytest.raises(httpx.HTTPStatusError, match="503"),
|
||||
):
|
||||
await _wait(
|
||||
_wait_handler(requests, [RECONNECT_FAILURES["503"]] * 5),
|
||||
asynchronous=asynchronous,
|
||||
)
|
||||
|
||||
assert len(requests) == 6
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("no_sleep")
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_wait_does_not_retry_a_client_error(asynchronous):
|
||||
requests: list[httpx.Request] = []
|
||||
with (
|
||||
pytest.warns(UserWarning, match="attempting reconnect"),
|
||||
pytest.raises(httpx.HTTPStatusError, match="404"),
|
||||
):
|
||||
await _wait(
|
||||
_wait_handler(
|
||||
requests, [lambda request: httpx.Response(404, request=request)]
|
||||
),
|
||||
asynchronous=asynchronous,
|
||||
)
|
||||
|
||||
assert len(requests) == 2
|
||||
|
||||
|
||||
def test_sync_wait_raises_the_run_error():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ from langgraph_sdk.client import (
|
||||
SyncThreadsClient,
|
||||
ThreadsClient,
|
||||
)
|
||||
from langgraph_sdk.schema import Checkpoint
|
||||
|
||||
CHECKPOINT: Checkpoint = {
|
||||
"thread_id": "thread_123",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": "cp_1",
|
||||
"checkpoint_map": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -56,3 +64,121 @@ def test_sync_threads_update_return_minimal():
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_threads_get_state_select():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_state("thread_123", select=["values.messages", "next"])
|
||||
await threads_client.get_state(
|
||||
"thread_123", checkpoint_id="cp_1", select=["values.messages"]
|
||||
)
|
||||
await threads_client.get_state(
|
||||
"thread_123",
|
||||
checkpoint=CHECKPOINT,
|
||||
select=["values.messages"],
|
||||
)
|
||||
await threads_client.get_state("thread_123")
|
||||
|
||||
latest, by_id, by_checkpoint, default = requests
|
||||
assert latest.method == "GET"
|
||||
assert latest.url.path == "/threads/thread_123/state"
|
||||
assert latest.url.params.get_list("select") == ["values.messages", "next"]
|
||||
assert by_id.method == "GET"
|
||||
assert by_id.url.path == "/threads/thread_123/state/cp_1"
|
||||
assert by_id.url.params.get_list("select") == ["values.messages"]
|
||||
assert by_checkpoint.method == "POST"
|
||||
assert by_checkpoint.url.path == "/threads/thread_123/state/checkpoint"
|
||||
assert json.loads(by_checkpoint.content) == {
|
||||
"checkpoint": CHECKPOINT,
|
||||
"subgraphs": False,
|
||||
"select": ["values.messages"],
|
||||
}
|
||||
assert "select" not in default.url.params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_threads_get_history_select():
|
||||
bodies: list[dict] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/threads/thread_123/history"
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_history("thread_123", select=["values.messages"])
|
||||
await threads_client.get_history("thread_123")
|
||||
|
||||
assert bodies == [{"limit": 10, "select": ["values.messages"]}, {"limit": 10}]
|
||||
|
||||
|
||||
def test_sync_threads_get_state_select():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get_state("thread_123", select=["values.messages", "next"])
|
||||
threads_client.get_state(
|
||||
"thread_123", checkpoint_id="cp_1", select=["values.messages"]
|
||||
)
|
||||
threads_client.get_state(
|
||||
"thread_123",
|
||||
checkpoint=CHECKPOINT,
|
||||
select=["values.messages"],
|
||||
)
|
||||
threads_client.get_state("thread_123")
|
||||
|
||||
latest, by_id, by_checkpoint, default = requests
|
||||
assert latest.method == "GET"
|
||||
assert latest.url.path == "/threads/thread_123/state"
|
||||
assert latest.url.params.get_list("select") == ["values.messages", "next"]
|
||||
assert by_id.method == "GET"
|
||||
assert by_id.url.path == "/threads/thread_123/state/cp_1"
|
||||
assert by_id.url.params.get_list("select") == ["values.messages"]
|
||||
assert by_checkpoint.method == "POST"
|
||||
assert by_checkpoint.url.path == "/threads/thread_123/state/checkpoint"
|
||||
assert json.loads(by_checkpoint.content) == {
|
||||
"checkpoint": CHECKPOINT,
|
||||
"subgraphs": False,
|
||||
"select": ["values.messages"],
|
||||
}
|
||||
assert "select" not in default.url.params
|
||||
|
||||
|
||||
def test_sync_threads_get_history_select():
|
||||
bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/threads/thread_123/history"
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get_history("thread_123", select=["values.messages"])
|
||||
threads_client.get_history("thread_123")
|
||||
|
||||
assert bodies == [{"limit": 10, "select": ["values.messages"]}, {"limit": 10}]
|
||||
Generated
+1
-1
@@ -301,7 +301,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.18"
|
||||
version = "1.2.19"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in new issue
Block a user