Compare commits

..
137 Commits
Author SHA1 Message Date
David DuongandGitHub 6ee112851c fix(cli): only render progress when in TTY (#4299)
Prevents logging terminal clear commands in non-TTY environments
(LangSmith / CI)
2025-04-16 23:41:35 +02:00
Tat Dat Duong 960f612dd7 Bump to 0.2.4 2025-04-16 23:34:54 +02:00
Tat Dat Duong 43af618bb5 Remove negation 2025-04-16 23:28:07 +02:00
Tat Dat Duong 9b87b45322 retrigger checks 2025-04-16 22:33:58 +02:00
David DuongandGitHub 39adc05357 fix(docs): fix typo (#4301) 2025-04-16 22:29:00 +02:00
Tat Dat Duong c5ac80d2f0 fix(docs): fix typo 2025-04-16 22:28:27 +02:00
Tat Dat Duong 2fc941c1df fix(cli): only render progress when in TTY
Prevents logging terminal clear commands in non-TTY environments (LangSmith / CI)
2025-04-16 21:25:21 +02:00
Vadym BardaandGitHub 7bafc5dd36 docs: add more prominent workflows link (#4297) 2025-04-16 18:29:54 +00:00
David DuongandGitHub c78588b995 feat(sdk-js): export more useStream types, allow loopback clients using globals (#4295) 2025-04-16 17:14:25 +02:00
Tat Dat Duong ecb15acb80 feat(sdk-js): export more useStream types, allow loopback clients using globals 2025-04-16 17:10:44 +02:00
Nuno CamposandGitHub 07ba931105 Stringify thread_id when of a different type (#4281) 2025-04-15 17:07:26 -07:00
Nuno Campos 88ccde6274 Same in get/update state/history 2025-04-15 17:01:01 -07:00
Nuno Campos 5cca153b72 Fix 2025-04-15 16:52:52 -07:00
HeardACatandGitHub 63ebb3a846 docs: rename parallel_workflow --> prompt_chaining_workflow (#4283)
Make the docs clearer within the prompt chaining section
2025-04-15 17:38:39 -04:00
Nuno Campos 48c08421fa Stringify thread_id when of a different type 2025-04-15 12:51:33 -07:00
William FHandGitHub cd967c40ac Add function templates (#4270) 2025-04-15 11:51:20 -07:00
Andrew NguonlyandGitHub 854b76addd docs: Update LangGraph Platform autoscaling docs (#4268) 2025-04-14 15:55:07 -07:00
Vadym BardaandGitHub 73b3535c4d langgraph: release 0.3.30 (#4267) 2025-04-14 17:05:10 -04:00
Nuno CamposandGitHub 3e0629c56c langgraph: support streaming messages from Command.update (#4250) 2025-04-14 12:25:20 -07:00
vbarda 04dd69b1cd simplify 2025-04-14 14:53:08 -04:00
Nuno CamposandGitHub ff22eb6495 langgraph: handle pydantic updates consistently in Command (#4255)
Fixes https://github.com/langchain-ai/langgraph/issues/3950
2025-04-14 11:47:22 -07:00
vbarda 07ca03ff15 lower depth 2025-04-14 14:12:40 -04:00
William FHandGitHub 6eea15ec3b Add store in platform (#4266) 2025-04-14 17:52:14 +00:00
vbarda 0e111b2f44 3.9 2025-04-14 13:23:26 -04:00
vbarda b526fe0a4b set max recursion depth 2025-04-14 13:21:00 -04:00
vbarda 062bf4d717 Merge branch 'vb/fix-command-messages' of github.com:langchain-ai/langgraph into vb/fix-command-messages 2025-04-14 13:20:48 -04:00
vbarda 173f4f6ccf Merge branch 'main' into vb/fix-command-messages 2025-04-14 13:13:49 -04:00
Vadym BardaandGitHub 9a45a5b0f2 Merge branch 'main' into vb/pydantic-command 2025-04-14 13:06:22 -04:00
vbarda 2c557e9e46 move to fields 2025-04-14 13:00:03 -04:00
William FHandGitHub 6c34e599ab Re-warn for omitted nav (#4265) 2025-04-14 09:56:41 -07:00
vbarda d4224a7abb Merge branch 'main' into vb/pydantic-command 2025-04-14 12:56:22 -04:00
Andrew NguonlyandGitHub c700dab97c docs: Add docs for LANGSMITH_TRACING env var (#4257) 2025-04-13 15:33:44 -07:00
vbarda 704b78b8fe tests 2025-04-12 10:45:10 -04:00
vbarda 2ed453debe factor out util 2025-04-12 10:34:02 -04:00
Nuno Campos 62b2580ad5 0.3.29 2025-04-11 16:21:09 -07:00
Nuno Campos dfbf0ddbcb Don't run branch reader in bg thread 2025-04-11 16:20:45 -07:00
Nuno CamposandGitHub 41bb20ee5e Reduce perf impact of set_context (#4256)
- call it less often
- find the run from the run manager at callsite
2025-04-11 15:32:42 -07:00
Nuno Campos 560d6a1f65 Reduce perf impact of set_context
- call it less often
- find the run from the run manager at callsite
2025-04-11 15:01:17 -07:00
vbarda dc6fa9ed30 langgraph: handle pydantic updates consistently in Command 2025-04-11 17:51:56 -04:00
Andrew NguonlyandGitHub a9be75f745 docs: Add Data Plane features sections for custom Postgres/Redis, tracing, telemetry, and licensing (#4254) 2025-04-11 14:48:20 -07:00
Nuno CamposandGitHub 20e3469296 Merge branch 'main' into vb/fix-command-messages 2025-04-11 14:27:26 -07:00
233cca1357 Update langgraph_platform.md (#4251)
Co-authored-by: Catherine <catherine@langchain.dev>
2025-04-11 15:29:11 -04:00
Andrew NguonlyandGitHub d1ac0a0e13 docs: Add alpha and beta labels for respective LangGraph Platform deployment options (#4249)
### Summary
Examples:

![image](https://github.com/user-attachments/assets/2a36a262-5373-498d-9907-19d5447fbb6a)


![image](https://github.com/user-attachments/assets/70671e08-34b6-40ed-964d-9d195ea8308d)


![image](https://github.com/user-attachments/assets/fcb877a6-475c-47a4-b8af-91cbdc00f89b)
2025-04-11 12:01:43 -07:00
vbarda 5071a6cd97 langgraph: support streaming messages from Command.update 2025-04-11 14:05:13 -04:00
Nuno CamposandGitHub 72d7b23638 Use tuple entry for control branch (#4248) 2025-04-11 10:29:44 -07:00
Nuno Campos 64aa1e6cd8 Use tuple entry for control branch 2025-04-11 09:53:54 -07:00
Nuno CamposandGitHub d6f2f0c90d Simplify path for control branch attached to every node (#4247)
- attached to every node to handle command/send return values
- used to be a full blown conditional edge, can be simpler by doing all
of it in a single function
2025-04-11 09:44:18 -07:00
Nuno Campos 5a7edead8c Lint 2025-04-11 09:20:10 -07:00
Nuno Campos 8ff5c43cf0 Avoid creating contexts for control branches 2025-04-11 09:10:58 -07:00
Nuno CamposandGitHub 0eb32a4251 Avoid validating node input more than once per superstep (#4242) 2025-04-11 09:06:07 -07:00
Nuno Campos 3d12a2df59 Simplify path for control branch attached to every node
- attached to every node to handle command/send return values
- used to be a full blown conditional edge, can be simpler by doing all of it in a single function
2025-04-11 09:00:16 -07:00
Nuno Campos 04d3c9d30f Use cache in attach_branch too 2025-04-11 08:38:14 -07:00
David DuongandGitHub cddcf35c09 fix(cli): invert assumed python_version / js_version check (#4245) 2025-04-11 16:49:46 +02:00
Tat Dat Duong 5eefc1d55d fix(cli): invert assumed python_version / js_version check 2025-04-11 16:38:26 +02:00
Vadym BardaandGitHub c9d4f1d77d langgraph: release 0.3.28 (#4243) 2025-04-10 21:17:25 -04:00
Vadym BardaandGitHub 1e2888ce39 langgraph: allow passing a list of retry policies (#4240)
* support passing `retry=(RetryPolicy(...), RetryPolicy())`
* fix bugs with `default_retry_on` and backoff calculation
* add tests
2025-04-10 21:16:27 -04:00
Nuno Campos 4d1b3370df Lint 2025-04-10 17:38:25 -07:00
Nuno Campos bf5017f6e0 Lint 2025-04-10 17:29:36 -07:00
Nuno Campos 64086aa814 Avoid validating node input more than once per superstep 2025-04-10 17:28:00 -07:00
David DuongandGitHub 2a7d48582f release(cli): 0.2.2 (#4241) 2025-04-11 01:40:35 +02:00
Tat Dat Duong b7bd87a063 release(cli): 0.2.2 2025-04-11 01:34:08 +02:00
David DuongandGitHub 2a825cc0e0 feat(cli): add multiplatform support (#4239)
- Uses new `install-node.sh` script already used for Python Gen UI
- Add default `node_version` / `python_version` based on provided
`graphs`

Closes #4115
2025-04-11 01:32:27 +02:00
Tat Dat Duong 436902e5a3 Consolidate node_version and python_version fix 2025-04-11 01:04:26 +02:00
Tat Dat Duong 0ac6a96c6e Fix up 2025-04-11 00:41:16 +02:00
Tat Dat Duong d9856d92af Another 3.9 fix 2025-04-11 00:09:34 +02:00
Tat Dat Duong b3487cbc49 Fix Python 3.9 2025-04-11 00:06:31 +02:00
Tat Dat Duong d06075cbcf Fix new style config 2025-04-11 00:04:44 +02:00
Tat Dat Duong e0be9ae2ef feat(cli): add multiplatform support
Uses new `install-node.sh` script already used for Python Gen UI, add default `node_version` / `python_version` based on provided `graphs`
2025-04-10 23:52:49 +02:00
Vadym BardaandGitHub 19cfe3a0a9 docs: fix title (#4238) 2025-04-10 17:17:36 -04:00
David DuongandGitHub 99a87abaa5 feat(docs): update typedoc references for auth (#4236) 2025-04-10 21:29:19 +02:00
William FHandGitHub a03cb0b16d CLI: Ensure correct api version is used (#4237)
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-10 15:56:07 +00:00
Tat Dat Duong 4be86b2a51 feat(docs): update typedoc references for auth 2025-04-10 17:03:03 +02:00
David DuongandGitHub 06e660a845 chore(docs,cli): remove python-only label for custom auth, add test for custom auth in CLI (#4233) 2025-04-10 16:02:12 +02:00
Tat Dat Duong 794dc1ae92 docs(api): add custom auth docs 2025-04-10 15:06:55 +02:00
Tat Dat Duong 6e7bfecbbc chore(docs,cli): remove python-only label for custom auth, add test for custom auth in CLI 2025-04-10 14:34:52 +02:00
fa080ea689 updates prereq banner (#4220)
<img width="796" alt="Screenshot 2025-04-09 at 1 20 14 PM"
src="https://github.com/user-attachments/assets/fe7409ed-c4cb-42d1-9e34-5adf4ca237e7"
/>

---------

Co-authored-by: Vadym Barda <vadim.barda@gmail.com>
2025-04-09 20:22:36 -04:00
David DuongandGitHub 9fb06fc5af feat(sdk-js): use event key instead of action, prevent retrying on HTTP 409 (#4223) 2025-04-10 01:54:54 +02:00
Tat Dat Duong 5debbb23ca feat(sdk-js): use event key instead of action, clean up interfaces 2025-04-10 01:52:44 +02:00
Nuno CamposandGitHub 7dcc760fd8 Validate other types in model_construct (#4200)
Resolves:
https://github.com/langchain-ai/langgraph/issues/4184
https://github.com/langchain-ai/langgraph/issues/4198 <- tested on
python 3.9 and 3.10
2025-04-09 15:58:11 -07:00
Vadym BardaandGitHub fdb9b9b8e0 checkpoint-postgres: add deprecation warning for ShallowPostgresSaver (#4219) 2025-04-09 11:31:33 -04:00
William FHandGitHub 2f51a15064 Update CLI (#4213) 2025-04-08 19:23:59 -07:00
Nuno CamposandGitHub d2acacfc8f Merge branch 'main' into wfh/_validate_more 2025-04-08 18:03:19 -07:00
Nuno CamposandGitHub 622a15b89e Remove pip from image (#4208)
After user installs, removes pip, setuptools, and wheel from the
resulting image.
2025-04-08 18:01:24 -07:00
Nuno CamposandGitHub bf50938de5 Update poetry version used in ci (#4212) 2025-04-08 18:00:28 -07:00
Nuno Campos d67a500cd9 Fix 2025-04-08 17:57:09 -07:00
Nuno Campos c6f5e561ec Update poetry version used in ci 2025-04-08 17:52:24 -07:00
William FHandGitHub 288fe12933 docs: Fix link (#4211) 2025-04-08 17:31:00 -07:00
William Fu-Hinthorn 5b58efc8d7 Update tests 2025-04-08 17:08:24 -07:00
David DuongandGitHub 2e1e582991 feat(sdk-js): add support for registering multiple events at once (#4209) 2025-04-09 01:58:49 +02:00
Tat Dat Duong 72260e64d5 Prevent casting 2025-04-09 01:57:50 +02:00
Tat Dat Duong 3193f5d063 feat(sdk-js): add support for registering multiple events at once 2025-04-09 01:56:32 +02:00
William Fu-Hinthorn 1b9093459c Remove pip from image
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-08 15:52:58 -07:00
Nuno Campos 392805938e 0.3.27 2025-04-08 15:04:46 -07:00
Nuno CamposandGitHub 4fb2aeacc7 Add checkpoint_during arg (#4169)
- This provides a new mode of execution where only the last checkpoint
is saved
- We save the last checkpoint no matter how the agent run is terminated
(success, error, interrupt, etc)
- This cuts down on cpu time spent on checkpointing, while not losing
any resilience benefits, given individual task writes are still saved
- If an error occurs and the run is retried, any tasks that completed
successfully before will be skipped (as currently)
- checkpoint_during=True is useful when you want to time-travel to inner
steps of a run
- The default value will remain the current behavior, ie.
checkpoint_during=True
2025-04-08 15:03:06 -07:00
David DuongandGitHub 8252668bcc release(sdk-js): 0.0.64 (#4207) 2025-04-08 23:46:23 +02:00
Tat Dat Duong 27e4b0fcfe release(sdk-js): 0.0.64 2025-04-08 23:45:03 +02:00
David DuongandGitHub ba388e25b3 feat(sdk-js): add auth types (#4199) 2025-04-08 23:44:33 +02:00
Nuno Campos 947a233fc5 Fix 2025-04-08 14:20:29 -07:00
Nuno Campos b76dc8ae0a Fix 2025-04-08 14:15:13 -07:00
Nuno Campos cbbfaba1fd Add comments 2025-04-08 14:07:08 -07:00
Nuno Campos e9aec77893 Add more tests 2025-04-08 14:07:02 -07:00
William FH 44691b69a3 Merge branch 'main' into wfh/_validate_more
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-08 13:57:37 -07:00
Nuno CamposandGitHub 067c4dd246 Implement simpler filtering of config keys in RemoteGraph (#4205) 2025-04-08 13:51:11 -07:00
Nuno Campos ccc21974e0 Implement simpler filtering of config keys in RemoteGraph 2025-04-08 13:44:31 -07:00
Nuno CamposandGitHub a6e66746f7 Make compatible with langchain-core 0.1 by conditionally importing _StreamingCallbackHandler (#4203) 2025-04-08 13:28:01 -07:00
Vadym BardaandGitHub 3a17df6106 langgraph: release 0.3.26 (#4204) 2025-04-08 14:53:10 -04:00
William Fu-Hinthorn 52c2837e42 Lint & handle arb types
Test on pydantic < 2

Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-08 11:49:37 -07:00
Nuno Campos cee6a450dc Lint 2025-04-08 10:52:28 -07:00
Nuno Campos 0b3bf37a55 Fix the rest 2025-04-08 10:47:55 -07:00
Nuno CamposandGitHub 305a676675 langgraph: raise GraphInterrupt only if used as a subgraph (#4202) 2025-04-08 10:45:45 -07:00
Nuno Campos 5b73e38c38 Make compatible with langchain-core 0.1 by conditionally importing _StreamingCallbackHandler 2025-04-08 10:44:12 -07:00
vbarda 41fb5ec77c Revert "add warning"
This reverts commit cff349e22e.
2025-04-08 13:38:49 -04:00
vbarda cff349e22e add warning 2025-04-08 13:34:47 -04:00
vbarda 8f32fc4819 update tests 2025-04-08 13:28:35 -04:00
vbarda 5690555394 langgraph: raise GraphInterrupt only if used as a subgraph 2025-04-08 12:51:36 -04:00
William Fu-Hinthorn 933d6aa8f5 Validate types.
My be too slow though. V1 handling is ugly.
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-08 09:31:54 -07:00
Tat Dat Duong 7d5621a84f Default type for TExtra 2025-04-08 17:10:43 +02:00
Tat Dat Duong 5f1213a1c7 Remove extra 2025-04-08 17:07:04 +02:00
Tat Dat Duong a98f9542fa Add unused extra generic for future typing of metadata 2025-04-08 17:06:51 +02:00
William Fu-Hinthorn d2e854b04f Merge branch 'main' into wfh/_validate_more 2025-04-08 06:34:38 -07:00
William FHandGitHub c5b118a672 Add admonitions about managed checkpointers (#4197)
If you're deploying with langgraph API, you don't need to manually
define a checkpointer. For folks who already know they'll be developing
with the api server, I'd like to save everyone time by making this more
clear in the docs on checkpointing.
2025-04-08 12:16:24 +00:00
Tat Dat Duong aee39605e0 Add missing types 2025-04-08 14:13:59 +02:00
lc-arjunandGitHub 72bec9161a Release js sdk 0.0.63 (#4192) 2025-04-07 18:40:59 -07:00
Nuno CamposandGitHub ae17e77522 feat: add assistant description to js sdk (#4191) 2025-04-07 18:37:49 -07:00
Arjun Natarajan a96fc75c55 add assistant description to js sdk 2025-04-07 21:06:34 -04:00
Nuno Campos d541ed90d5 Save Sends unconditionally 2025-04-07 16:45:26 -07:00
William Fu-Hinthorn 7d7708fe42 Validate more 2025-04-07 11:29:32 -07:00
Tat Dat Duong c757247858 feat(sdk-js): add auth types 2025-04-07 20:29:14 +02:00
Nuno Campos 5a0228cb13 Add test 2025-04-04 16:00:28 -07:00
Nuno Campos 4abfc7702d Subgraphs inherit checkpoint mode 2025-04-04 16:00:22 -07:00
Nuno Campos a5495e84c8 Add another test 2025-04-04 15:42:11 -07:00
Nuno Campos 4f353dac31 Fix assignment of pending writes 2025-04-04 14:41:10 -07:00
Nuno CamposandGitHub 4c89bb39d4 Add benchmark script for typed dict version of existing wide state benchmark (#4174)
- to easily compare perf impact of using pydantic, data class, or typed
dict for same workload
2025-04-04 18:37:57 +00:00
Nuno Campos 7ebd6f5e1f Better test 2025-04-04 10:01:35 -07:00
Nuno Campos 0a1dd7a01a Do same thing for writes 2025-04-03 17:31:34 -07:00
Nuno Campos 7e08339335 mypy is dumb 2025-04-03 16:55:23 -07:00
Nuno Campos e1d4b5552d Add checkpoint_during arg
- This provides a new mode of execution where only the last checkpoint is saved
- We save the last checkpoint no matter how the agent run is terminated (success, error, interrupt, etc)
- This cuts down on cpu time spent on checkpointing, while not losing any resilience benefits, given individual task writes are still saved
- If an error occurs and the run is retried, any tasks that completed successfully before will be skipped (as currently)
- checkpoint_during=True is useful when you want to time-travel to inner steps of a run
- The default value will remain the current behavior, ie. checkpoint_during=True
2025-04-03 16:51:53 -07:00
97 changed files with 4321 additions and 1187 deletions
+1 -2
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
build:
@@ -71,4 +71,3 @@ jobs:
working-directory: libs/cli/js-examples
run: |
langgraph build -t langgraph-test-e
+1 -7
View File
@@ -9,7 +9,7 @@ on:
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
# This env var allows us to get inline annotations when ruff has complaints.
RUFF_OUTPUT_FORMAT: github
@@ -50,12 +50,6 @@ jobs:
working-directory: ${{ inputs.working-directory }}
run: poetry check
- name: Check lock file
if: steps.changed-files.outputs.all
shell: bash
working-directory: ${{ inputs.working-directory }}
run: poetry check --lock
- name: Install dependencies
if: steps.changed-files.outputs.all
# Also installs dev/lint/test/typing dependencies, to ensure we have
+1 -7
View File
@@ -9,7 +9,7 @@ on:
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
build:
@@ -39,12 +39,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_RO_TOKEN }}
- name: Check Lock
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
poetry check --lock
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.working-directory }}
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
build:
+1 -1
View File
@@ -9,7 +9,7 @@ on:
description: "From which folder this pipeline executes"
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
PYTHON_VERSION: "3.10"
jobs:
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
build:
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- "libs/**"
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
benchmark:
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- "libs/**"
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
benchmark:
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
cancel-in-progress: true
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
changes:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
permissions:
contents: read
+6 -6
View File
@@ -12,7 +12,7 @@ on:
workflow_dispatch:
env:
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
markdown-link-check:
@@ -42,8 +42,8 @@ jobs:
- name: Check README.md is in sync
run: |
if ! diff -q README.md libs/langgraph/README.md >/dev/null; then
echo "README.md is out of sync with libs/langgraph/README.md"
diff -C 3 README.md libs/langgraph/README.md
exit 1
fi
if ! diff -q README.md libs/langgraph/README.md >/dev/null; then
echo "README.md is out of sync with libs/langgraph/README.md"
diff -C 3 README.md libs/langgraph/README.md
exit 1
fi
+1 -1
View File
@@ -10,7 +10,7 @@ on:
env:
PYTHON_VERSION: "3.11"
POETRY_VERSION: "1.7.1"
POETRY_VERSION: "2.1.2"
jobs:
build:
+3 -3
View File
@@ -9,7 +9,7 @@ on:
type: string
description: "JSON string of changed files"
schedule:
- cron: '0 13 * * *'
- cron: "0 13 * * *"
defaults:
run:
@@ -30,12 +30,12 @@ jobs:
uses: "./.github/actions/poetry_setup"
with:
python-version: 3.11
poetry-version: 1.7.1
poetry-version: 2.1.2
cache-key: test-langgraph-notebooks
- name: Install dependencies
run: |
poetry install --with test
poetry install --with test --no-root
poetry run pip install jupyter
- name: Start services
+3
View File
@@ -40,6 +40,9 @@ agent.invoke(
)
```
> [!TIP]
> Check out [this guide](https://langchain-ai.github.io/langgraph/tutorials/workflows/) that walks through implementing common patterns (workflows and agents) in LangGraph.
## Why use LangGraph?
LangGraph is built for developers who want to build powerful, adaptable AI agents. Developers choose LangGraph for:
+1 -1
View File
@@ -1,4 +1,4 @@
# How to Deploy to Cloud SaaS
# How to Deploy to Cloud SaaS (Beta)
Before deploying, review the [conceptual guide for the Cloud SaaS](../../concepts/langgraph_cloud.md) deployment option.
@@ -1,4 +1,4 @@
# How to Deploy Self-Hosted Control Plane
# How to Deploy Self-Hosted Control Plane (Beta)
Before deploying, review the [conceptual guide for the Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployment option.
@@ -1,4 +1,4 @@
# How to Deploy Self-Hosted Data Plane
# How to Deploy Self-Hosted Data Plane (Beta)
Before deploying, review the [conceptual guide for the Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) deployment option.
+5 -2
View File
@@ -1,6 +1,9 @@
# How to integrate LangGraph into your React application
!!! info "Prerequisites" - [LangGraph Platform](../../concepts/langgraph_platform.md) - [LangGraph Server](../../concepts/langgraph_server.md)
!!! info "Prerequisites"
- [LangGraph Platform](../../concepts/langgraph_platform.md)
- [LangGraph Server](../../concepts/langgraph_server.md)
The `useStream()` React hook provides a seamless way to integrate LangGraph into your React applications. It handles all the complexities of streaming, state management, and branching logic, letting you focus on building great chat experiences.
@@ -155,7 +158,7 @@ export default function HomePage() {
}
```
Under the hood, the `useStream()` hook will use the `streamMode: "messages-key"` to receive a stream of messages (i.e. individual LLM tokens) from any LangChain chat model invocations inside your graph nodes. Learn more about messages streaming in the [How to stream messages from your graph](./stream_messages.md) guide.
Under the hood, the `useStream()` hook will use the `streamMode: "messages-tuple"` to receive a stream of messages (i.e. individual LLM tokens) from any LangChain chat model invocations inside your graph nodes. Learn more about messages streaming in the [How to stream messages from your graph](./stream_messages.md) guide.
### Interrupts
+17 -9
View File
@@ -1,6 +1,6 @@
# Environment Variables
The LangGraph Cloud Server supports specific environment variables for configuring a deployment.
The LangGraph Server supports specific environment variables for configuring a deployment.
## `BG_JOB_ISOLATED_LOOPS`
@@ -32,7 +32,7 @@ See <a href="https://docs.smith.langchain.com/how_to_guides/tracing/sample_trace
## `LANGGRAPH_AUTH_TYPE`
Type of authentication for the LangGraph Cloud Server deployment. Valid values: `langsmith`, `noop`.
Type of authentication for the LangGraph Server deployment. Valid values: `langsmith`, `noop`.
For deployments to LangGraph Cloud, this environment variable is set automatically. For local development or deployments where authentication is handled externally (e.g. self-hosted), set this environment variable to `noop`.
@@ -44,19 +44,27 @@ Set this environment variable to have a BYOC deployment send traces to a self-ho
`SELF_HOSTED_LANGSMITH_HOSTNAME` is the hostname of the self-hosted LangSmith instance. It must be accessible to the BYOC deployment. `LANGSMITH_API_KEY` is a LangSmith API generated from the self-hosted LangSmith instance.
## `LANGSMITH_TRACING`
!!! info "Only for Self-Hosted Data Plane, Self-Hosted Control Plane, and Standalone Container"
Disabling LangSmith tracing is only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md), [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md), and [Standalone Container](../../concepts/langgraph_standalone_container.md) deployments.
Set `LANGSMITH_TRACING` to `false` to disable tracing to LangSmith.
## `LOG_LEVEL`
Configure [log level](https://docs.python.org/3/library/logging.html#logging-levels). Defaults to `INFO`.
## `N_JOBS_PER_WORKER`
Number of jobs per worker for the LangGraph Cloud task queue. Defaults to `10`.
Number of jobs per worker for the LangGraph Server task queue. Defaults to `10`.
## `POSTGRES_URI_CUSTOM`
For [Bring Your Own Cloud (BYOC)](../../concepts/bring_your_own_cloud.md) deployments only.
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
Custom Postgres instances are only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployments.
Specify `POSTGRES_URI_CUSTOM` to use an externally managed Postgres instance. The value of `POSTGRES_URI_CUSTOM` must be a valid [Postgres connection URI](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS).
Specify `POSTGRES_URI_CUSTOM` to use a custom Postgres instance. The value of `POSTGRES_URI_CUSTOM` must be a valid [Postgres connection URI](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS).
Postgres:
@@ -73,11 +81,11 @@ Control Plane Functionality:
Database Connectivity:
- The externally managed Postgres instance must be accessible by the LangGraph Server service in the ECS cluster. The BYOC user is responsible for ensuring connectivity.
- For example, if an AWS RDS Postgres instance is provisioned, it can be provisioned in the same VPC (`langgraph-cloud-vpc`) as the ECS cluster with the `langgraph-cloud-service-sg` security group to ensure connectivity.
- The custom Postgres instance must be accessible by the LangGraph Server. The user is responsible for ensuring connectivity.
## `REDIS_URI_CUSTOM`
For [Bring Your Own Cloud (BYOC)](../../concepts/bring_your_own_cloud.md) deployments only.
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
Custom Redis instances are only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployments.
Specify `REDIS_URI_CUSTOM` to use an externally managed Redis instance. The value of `REDIS_URI_CUSTOM` must be a valid [Redis connection URI](https://redis-py.readthedocs.io/en/stable/connections.html#redis.Redis.from_url).
Specify `REDIS_URI_CUSTOM` to use a custom Redis instance. The value of `REDIS_URI_CUSTOM` must be a valid [Redis connection URI](https://redis-py.readthedocs.io/en/stable/connections.html#redis.Redis.from_url).
+3 -6
View File
@@ -2,10 +2,6 @@
LangGraph Platform provides a flexible authentication and authorization system that can integrate with most authentication schemes.
!!! note "Python only"
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
## Core Concepts
### Authentication vs Authorization
@@ -146,7 +142,7 @@ The returned user information is available:
After authentication, LangGraph calls your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers to control access to specific resources (e.g., threads, assistants, crons). These handlers can:
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](##supported-actions) for the list of types the value can take for each action.
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](#supported-actions) for the list of types the value can take for each action.
2. Filter resources by metadata during search/list or read operations by returning a [filter dictionary](#filter-operations).
3. Raise an HTTP exception if access is denied.
@@ -289,7 +285,7 @@ async def on_assistant_create(
)
```
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action. Requests to create, update,
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action.
### Filter Operations {#filter-operations}
@@ -423,6 +419,7 @@ Here are all the supported action handlers:
| | `@auth.on.crons.search` | Listing cron jobs | [`CronsSearch`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsSearch) |
???+ note "About Runs"
Runs are scoped to their parent thread for access control. This means permissions are typically inherited from the thread, reflecting the conversational nature of the data model. All run operations (reading, listing) except creation are controlled by the thread's handlers.
There is a specific `create_run` handler for creating new runs because it had more arguments that you can view in the handler.
+3 -3
View File
@@ -10,11 +10,11 @@
There are 4 main options for deploying with the LangGraph Platform:
1. **[Cloud SaaS](#cloud-saas)**: Available for **Plus** and **Enterprise** plans.
1. **<a href="#cloud-saas">Cloud SaaS<sup>(Beta)</sup></a>**: Available for **Plus** and **Enterprise** plans.
1. **[Self-Hosted Data Plane](#self-hosted-data-plane)**: Available for the **Enterprise** plan.
1. **<a href="#self-hosted-data-plane">Self-Hosted Data Plane<sup>(Beta)</sup></a>**: Available for the **Enterprise** plan.
1. **[Self-Hosted Control Plane](#self-hosted-control-plane)**: Available for the **Enterprise** plan.
1. **<a href="#self-hosted-control-plane">Self-Hosted Control Plane<sup>(Beta)</sup></a>**: Available for the **Enterprise** plan.
1. **[Standalone Container](#standalone-container)**: Available for all plans.
+3 -3
View File
@@ -76,7 +76,7 @@ The LangGraph Platform comprises several components that work together to suppor
### Deployment Options
- [Cloud SaaS](../concepts/langgraph_cloud.md): Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
- [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md): Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
- [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md#control-plane-ui): Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md) and deploy LangGraph Servers to your cloud. You manage everything.
- <a href="./langgraph_cloud/">Cloud SaaS<sup>(Beta)</sup></a>: Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
- <a href="./langgraph_self_hosted_data_plane/">Self-Hosted Data Plane<sup>(Beta)</sup></a>: Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
- <a href="./langgraph_self_hosted_control_plane/">Self-Hosted Control Plane<sup>(Beta)</sup></a>: Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. You manage everything.
- [Standalone Container](../concepts/langgraph_standalone_container.md): Deploy LangGraph Server Docker images however you like.
+1 -1
View File
@@ -1,4 +1,4 @@
# Cloud SaaS
# Cloud SaaS (Beta)
To deploy a [LangGraph Server](../concepts/langgraph_server.md), follow the how-to guide for [how to deploy to Cloud SaaS](../cloud/deployment/cloud.md).
+53 -7
View File
@@ -44,21 +44,24 @@ Feature Differences:
### Autoscaling
[`Production` type](../concepts/langgraph_control_plane.md#deployment-types) deployments automatically scale up to 10 containers. Scaling is based on the current request load for a single container. Specifically, the autoscaling implementation scales the deployment so that each container is processing about 10 concurrent requests. For example...
[`Production` type](../concepts/langgraph_control_plane.md#deployment-types) deployments automatically scale up to 10 containers. Scaling is based on 3 metrics:
- If the deployment is processing 20 concurrent requests, the deployment will scale up from 1 container to 2 containers (20 requests / 2 containers = 10 requests per container).
- If a deployment of 2 containers is processing 10 requests, the deployment will scale down from 2 containers to 1 container (10 requests / 1 container = 10 requests per container).
1. CPU utilization
1. Memory utilization
1. Number of pending (in progress) [runs](../concepts/langgraph_server.md#runs)
10 concurrent requests per container is the target threshold. However, 10 concurrent requests per container is not a hard limit. The number of concurrent requests can exceed 10 if there is a sudden burst of requests.
For CPU utilization, the autoscaler targets 75% utilization. This means the autoscaler will scale the number of containers up or down to ensure that CPU utilization is at or near 75%. For memory utilization, the autoscaler targets 75% utilization as well.
Scale down actions are delayed for 30 minutes before any action is taken. In other words, if the autoscaling implementation decides to scale down a deployment, it will first wait for 30 minutes before scaling down. After 30 minutes, the concurrency metric is recomputed and the deployment will scale down if the concurrency metric has met the target threshold. Otherwise, the deployment remains scaled up. This "cool down" period ensures that deployments do not scale up and down too frequently.
For number of pending runs, the autoscaler targets 10 pending runs. For example, if the current number of containers is 1, but the number of pending runs in 20, the autoscaler will scale up the deployment to 2 containers (20 pending runs / 2 containers = 10 pending runs per container).
In the future, the autoscaling implementation may evolve to accommodate other metrics such as background run queue size.
Each metric is computed independently and the autoscaler will determine the scaling action based on the metric that results in the most number of containers.
Scale down actions are delayed for 30 minutes before any action is taken. In other words, if the autoscaler decides to scale down a deployment, it will first wait for 30 minutes before scaling down. After 30 minutes, the metrics are recomputed and the deployment will scale down if the recomputed metrics result in a lower number of containers than the current number. Otherwise, the deployment remains scaled up. This "cool down" period ensures that deployments do not scale up and down too frequently.
### Static IP Addresses
!!! info "Only for Cloud SaaS"
Static IP addresses are only available for [Cloud SaaS](../concepts/langgraph_cloud.md).
Static IP addresses are only available for [Cloud SaaS](../concepts/langgraph_cloud.md) deployments.
All traffic from deployments created after January 6th 2025 will come through a NAT gateway. This NAT gateway will have several static IP addresses depending on the data region. Refer to the table below for the list of static IP addresses:
@@ -72,3 +75,46 @@ All traffic from deployments created after January 6th 2025 will come through a
| 34.169.88.30 | 34.91.238.184 |
| 34.19.93.202 | 35.204.101.241 |
| 34.19.34.50 | 35.204.48.32 |
### Custom Postgres
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
Custom Postgres instances are only available for [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md) deployments.
A custom Postgres instance can be used instead of the [one automatically created by the control plane](./langgraph_control_plane.md#database-provisioning). Specify the [`POSTGRES_URI_CUSTOM`](../cloud/reference/env_var.md#postgres_uri_custom) environment variable to use a custom Postgres instance.
Multiple deployments can share the same Postgres instance. For example, for `Deployment A`, `POSTGRES_URI_CUSTOM` can be set to `postgres://<user>:<password>@/<database_name_1>?host=<hostname_1>` and for `Deployment B`, `POSTGRES_URI_CUSTOM` can be set to `postgres://<user>:<password>@/<database_name_2>?host=<hostname_1>`. `<database_name_1>` and `database_name_2` are different databases within the same instance, but `<hostname_1>` is shared. **The same database cannot be used for separate deployments**.
### Custom Redis
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
Custom Redis instances are only available for [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md) deployments.
A custom Redis instance can be used instead of the one automatically created by the control plane. Specify the [REDIS_URI_CUSTOM](../cloud/reference/env_var.md#redis_uri_custom) environment variable to use a custom Redis instance.
Multiple deployments can share the same Redis instance. For example, for `Deployment A`, `REDIS_URI_CUSTOM` can be set to `redis://<hostname_1>:<port>/1` and for `Deployment B`, `REDIS_URI_CUSTOM` can be set to `redis://<hostname_1>:<port>/2`. `1` and `2` are different database numbers within the same instance, but `<hostname_1>` is shared. **The same database number cannot be used for separate deployments**.
### LangSmith Tracing
LangGraph Server is automatically configured to send traces to LangSmith. See the table below for details with respect to each deployment option.
| Cloud SaaS | Self-Hosted Data Plane | Self-Hosted Control Plane | Standalone Container |
|------------|------------------------|---------------------------|----------------------|
| Required<br><br>Trace to LangSmith SaaS. | Optional<br><br>Disable tracing or trace to LangSmith SaaS. | Optional<br><br>Disable tracing or trace to Self-Hosted LangSmith. | Optional<br><br>Disable tracing, trace to LangSmith SaaS, or trace to Self-Hosted LangSmith. |
### Telemetry
LangGraph Server is automatically configured to report telemetry metadata for billing purposes. See the table below for details with respect to each deployment option.
| Cloud SaaS | Self-Hosted Data Plane | Self-Hosted Control Plane | Standalone Container |
|------------|------------------------|---------------------------|----------------------|
| Telemetry sent to LangSmith SaaS. | Telemetry sent to LangSmith SaaS. | Self-reported usage (audit) for air-gapped license key.<br><br>Telemetry sent to LangSmith SaaS for LangGraph Platform License Key. | Self-reported usage (audit) for air-gapped license key.<br><br>Telemetry sent to LangSmith SaaS for LangGraph Platform License Key. |
### Licensing
LangGraph Server is automatically configured to perform license key validation. See the table below for details with respect to each deployment option.
| Cloud SaaS | Self-Hosted Data Plane | Self-Hosted Control Plane | Standalone Container |
|------------|------------------------|---------------------------|----------------------|
| LangSmith API Key validated against LangSmith SaaS. | LangSmith API Key validated against LangSmith SaaS. | Air-gapped license key or LangGraph Platform License Key validated against LangSmith SaaS. | Air-gapped license key or LangGraph Platform License Key validated against LangSmith SaaS. |
+4
View File
@@ -5,6 +5,10 @@ search:
# LangGraph Platform
Watch this 4-minute overview of LangGraph Platform to see how it helps you build, deploy, and evaluate agentic applications.
<iframe width="560" height="315" src="https://www.youtube.com/embed/pfAQxBS5z88?si=XGS6Chydn6lhSO1S" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## Overview
LangGraph Platform is a commercial solution for deploying agentic applications to production, built on the open-source [LangGraph framework](./high_level.md).
@@ -1,4 +1,4 @@
# Self-Hosted Control Plane
# Self-Hosted Control Plane (Beta)
To deploy a [LangGraph Server](../concepts/langgraph_server.md), follow the how-to guide for [how to deploy the Self-Hosted Control Plane](../cloud/deployment/self_hosted_control_plane.md).
@@ -1,4 +1,4 @@
# Self-Hosted Data Plane
# Self-Hosted Data Plane (Beta)
To deploy a [LangGraph Server](../concepts/langgraph_server.md), follow the how-to guide for [how to deploy the Self-Hosted Data Plane](../cloud/deployment/self_hosted_data_plane.md).
+14 -5
View File
@@ -4,6 +4,10 @@ LangGraph has a built-in persistence layer, implemented through checkpointers. W
![Checkpoints](img/persistence/checkpoints.jpg)
!!! info "LangGraph API handles checkpointing automatically"
When using the LangGraph API, you don't need to implement or configure checkpointers manually. The API handles all persistence infrastructure for you behind the scenes.
## Threads
A thread is a unique ID or [thread identifier](#threads) assigned to each checkpoint saved by a checkpointer. When invoking graph with a checkpointer, you **must** specify a `thread_id` as part of the `configurable` portion of the config:
@@ -26,7 +30,7 @@ Let's see what checkpoints are saved when a simple graph is invoked as follows:
```python
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.memory import InMemorySaver
from typing import Annotated
from typing_extensions import TypedDict
from operator import add
@@ -49,7 +53,7 @@ workflow.add_edge(START, "node_a")
workflow.add_edge("node_a", "node_b")
workflow.add_edge("node_b", END)
checkpointer = MemorySaver()
checkpointer = InMemorySaver()
graph = workflow.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
@@ -223,6 +227,10 @@ But, what if we want to retain some information *across threads*? Consider the c
With checkpointers alone, we cannot share information across threads. This motivates the need for the [`Store`](../reference/store.md#langgraph.store.base.BaseStore) interface. As an illustration, we can define an `InMemoryStore` to store information about a user across threads. We simply compile our graph with a checkpointer, as before, and with our new `in_memory_store` variable.
!!! info "LangGraph API handles stores automatically"
When using the LangGraph API, you don't need to implement or configure stores manually. The API handles all storage infrastructure for you behind the scenes.
### Basic Usage
First, let's showcase this in isolation without using LangGraph.
@@ -324,10 +332,10 @@ store.put(
With this all in place, we use the `in_memory_store` in LangGraph. The `in_memory_store` works hand-in-hand with the checkpointer: the checkpointer saves state to threads, as discussed above, and the `in_memory_store` allows us to store arbitrary information for access *across* threads. We compile the graph with both the checkpointer and the `in_memory_store` as follows.
```python
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.memory import InMemorySaver
# We need this because we want to enable threads (conversations)
checkpointer = MemorySaver()
checkpointer = InMemorySaver()
# ... Define the graph ...
@@ -440,6 +448,7 @@ Under the hood, checkpointing is powered by checkpointer objects that conform to
* `langgraph-checkpoint-sqlite`: An implementation of LangGraph checkpointer that uses SQLite database ([SqliteSaver][langgraph.checkpoint.sqlite.SqliteSaver] / [AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]). Ideal for experimentation and local workflows. Needs to be installed separately.
* `langgraph-checkpoint-postgres`: An advanced checkpointer that uses Postgres database ([PostgresSaver][langgraph.checkpoint.postgres.PostgresSaver] / [AsyncPostgresSaver][langgraph.checkpoint.postgres.aio.AsyncPostgresSaver]), used in LangGraph Cloud. Ideal for using in production. Needs to be installed separately.
### Checkpointer interface
Each checkpointer conforms to [BaseCheckpointSaver][langgraph.checkpoint.base.BaseCheckpointSaver] interface and implements the following methods:
@@ -452,7 +461,7 @@ Each checkpointer conforms to [BaseCheckpointSaver][langgraph.checkpoint.base.Ba
If the checkpointer is used with asynchronous graph execution (i.e. executing the graph via `.ainvoke`, `.astream`, `.abatch`), asynchronous versions of the above methods will be used (`.aput`, `.aput_writes`, `.aget_tuple`, `.alist`).
!!! note Note
For running your graph asynchronously, you can use `MemorySaver`, or async versions of Sqlite/Postgres checkpointers -- `AsyncSqliteSaver` / `AsyncPostgresSaver` checkpointers.
For running your graph asynchronously, you can use `InMemorySaver`, or async versions of Sqlite/Postgres checkpointers -- `AsyncSqliteSaver` / `AsyncPostgresSaver` checkpointers.
### Serializer
+1 -1
View File
@@ -4,7 +4,7 @@
## How we use Postgres
Postgres is the persistence layer for all user and run data in LGP. This stores both checkpoints (see more info [here](./persistence.md)) as well as the server resources (threads, runs, assistants and crons).
Postgres is the persistence layer for all user, run, and long-term memory data in LGP. This stores both checkpoints (see more info [here](./persistence.md)), server resources (threads, runs, assistants and crons), as well as items saved in the long-term memory store (see more info [here](./persistence.md#memory-store)).
## How we use Redis
-4
View File
@@ -9,10 +9,6 @@
For a more guided walkthrough, see [**setting up custom authentication**](../../tutorials/auth/getting_started.md) tutorial.
???+ note "Python only"
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
???+ note "Support by deployment type"
Custom auth is supported for all deployments in the **managed LangGraph Cloud**, as well as **Enterprise** self-hosted plans. It is not supported for **Lite** self-hosted plans.
-4
View File
@@ -8,10 +8,6 @@ Defining a custom app object lets you add any routes you'd like, so you can do a
Below is an example using FastAPI.
???+ note "Python only"
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.26`.
## Create app
Starting from an **existing** LangGraph Platform application, add the following custom route code to your `webapp.py` file. If you are starting from scratch, you can create a new app from a template using the CLI.
@@ -16,6 +16,10 @@
" - [Memory](../../concepts/memory/)\n",
" - [Chat Models](https://python.langchain.com/docs/concepts/chat_models/)\n",
"\n",
"!!! info \"Not needed for LangGraph API users\"\n",
"\n",
" If you're using the LangGraph API, you needn't manually implement a checkpointer. The API automatically handles checkpointing for you. This guide is relevant when implementing LangGraph in your own custom server.\n",
"\n",
"Many AI applications need memory to share context across multiple interactions on the same [thread](../../concepts/persistence#threads) (e.g., multiple turns of a conversation). In LangGraph functional API, this kind of memory can be added to any [entrypoint()][langgraph.func.entrypoint] workflow using [thread-level persistence](https://langchain-ai.github.io/langgraph/concepts/persistence).\n",
"\n",
"When creating a LangGraph workflow, you can set it up to persist its results by using a [checkpointer](https://langchain-ai.github.io/langgraph/reference/checkpoints/#basecheckpointsaver):\n",
+4
View File
@@ -31,6 +31,10 @@
" </p>\n",
"</div> \n",
"\n",
"!!! info \"Not needed for LangGraph API users\"\n",
"\n",
" If you're using the LangGraph API, you needn't manually implement a checkpointer. The API automatically handles checkpointing for you. This guide is relevant when implementing LangGraph in your own custom server.\n",
"\n",
"Many AI applications need memory to share context across multiple interactions. In LangGraph, this kind of memory can be added to any [StateGraph](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.StateGraph) using [thread-level persistence](https://langchain-ai.github.io/langgraph/concepts/persistence) .\n",
"\n",
"When creating any LangGraph graph, you can set it up to persist its state by adding a [checkpointer](https://langchain-ai.github.io/langgraph/reference/checkpoints/#basecheckpointsaver) when compiling the graph:\n",
+5 -1
View File
@@ -26,6 +26,10 @@
" </p>\n",
"</div> \n",
"\n",
"!!! info \"Not needed for LangGraph API users\"\n",
"\n",
" If you're using the LangGraph API, you needn't manually implement a checkpointer. The API automatically handles checkpointing for you. This guide is relevant when implementing LangGraph in your own custom server.\n",
"\n",
"When creating LangGraph agents, you can also set them up so that they persist their state. This allows you to do things like interact with an agent multiple times and have it remember previous interactions.\n",
"\n",
"This how-to guide shows how to use `Postgres` as the backend for persisting checkpoint state using the [`langgraph-checkpoint-postgres`](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres) library.\n",
@@ -44,7 +48,7 @@
"...\n",
"```\n",
"\n",
"!!! info \"Setup\"",
"!!! info \"Setup\"\n",
"\n",
" You need to run `.setup()` once on your checkpointer to initialize the database before you can use it."
]
+1 -1
View File
@@ -2,7 +2,7 @@
!!! tip "Prerequisites"
This guide assumes familiarity with the [LangGraph Platform](../../concepts/index.md#langgraph-platform), [Persistence](../../concepts/persistence.md), and [Cross-thread persistence](../../concepts/store.md) concepts.
This guide assumes familiarity with the [LangGraph Platform](../../concepts/index.md#langgraph-platform), [Persistence](../../concepts/persistence.md), and [Cross-thread persistence](../../concepts/persistence.md#memory-store) concepts.
???+ note "LangGraph platform only"
+4 -1
View File
@@ -1,6 +1,6 @@
---
hide_comments: true
title: Home
title: LangGraph
---
<script>
@@ -23,6 +23,9 @@ title: Home
.md-content h1 {
display: none;
}
.md-header__topic {
display: none;
}
</style>
{!../README.md!}
+3 -3
View File
@@ -17,9 +17,9 @@ Get started deploying your LangGraph applications locally or on the cloud with
## Deployment Options
- [Cloud SaaS](../concepts/langgraph_cloud.md): Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
- [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md): Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
- [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md#control-plane-ui): Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md) and deploy LangGraph Servers to your cloud. You manage everything.
- <a href="../../concepts/langgraph_cloud/">Cloud SaaS<sup>(Beta)</sup></a>: Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
- <a href="../../concepts/langgraph_self_hosted_data_plane/">Self-Hosted Data Plane<sup>(Beta)</sup></a>: Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
- <a href="../../concepts/langgraph_self_hosted_control_plane/">Self-Hosted Control Plane<sup>(Beta)</sup></a>: Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. You manage everything.
- [Standalone Container](../concepts/langgraph_standalone_container.md): Deploy LangGraph Server Docker images however you like.
A quick comparison...
+2 -2
View File
@@ -222,7 +222,7 @@ As noted in the Anthropic blog on `Building Effective Agents`:
@entrypoint()
def parallel_workflow(topic: str):
def prompt_chaining_workflow(topic: str):
original_joke = generate_joke(topic).result()
if check_punchline(original_joke) == "Pass":
return original_joke
@@ -231,7 +231,7 @@ As noted in the Anthropic blog on `Building Effective Agents`:
return polish_joke(improved_joke).result()
# Invoke
for step in parallel_workflow.stream("cats", stream_mode="updates"):
for step in prompt_chaining_workflow.stream("cats", stream_mode="updates"):
print(step)
print("\n")
```
+24 -10
View File
@@ -57,12 +57,16 @@ plugins:
separator: '[\s\u200b\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;'
- autorefs
- mkdocstrings:
custom_templates: templates
handlers:
python:
import:
- https://docs.python.org/3/objects.inv
- https://python.langchain.com/api_reference/objects.inv
options:
preload_modules:
- langchain
- langchain_core
enable_inventory: true
members_order: source
allow_inspection: true
@@ -75,7 +79,10 @@ plugins:
docstring_style: google
docstring_section_style: list
show_root_toc_entry: false
show_signature: true
show_signature_annotations: true
separate_signature: true
line_length: 60
show_symbol_type_heading: true
show_symbol_type_toc: true
signature_crossrefs: true
@@ -213,8 +220,8 @@ nav:
- how-tos/ttl/configure_ttl.md
- Authentication & Access Control:
- Authentication & Access Control: how-tos#authentication-access-control
- cloud/how-tos/auth/custom_auth_new.md
- cloud/how-tos/auth/openapi_security_new.md
- how-tos/auth/custom_auth.md
- how-tos/auth/openapi_security.md
- Assistants:
- Assistants: how-tos#assistants
- cloud/how-tos/configuration_cloud.md
@@ -256,6 +263,11 @@ nav:
- cloud/how-tos/webhooks.md
- Cron Jobs:
- cloud/how-tos/cron_jobs.md
- Modifying the API:
- Modifying the API: how-tos#modifying-the-api
- how-tos/http/custom_lifespan.md
- how-tos/http/custom_middleware.md
- how-tos/http/custom_routes.md
- LangGraph Studio:
- LangGraph Studio: how-tos#langgraph-studio
- cloud/how-tos/test_deployment.md
@@ -265,6 +277,7 @@ nav:
- cloud/how-tos/datasets_studio.md
- cloud/how-tos/iterate_graph_studio.md
- cloud/how-tos/clone_traces_studio.md
- how-tos/local-studio.md
- Concepts:
- concepts/index.md
- LangGraph:
@@ -273,8 +286,9 @@ nav:
- concepts/low_level.md
- concepts/agentic_concepts.md
- concepts/multi_agent.md
- concepts/breakpoints
- concepts/breakpoints.md
- concepts/human_in_the_loop.md
- concepts/v0-human-in-the-loop.md
- concepts/time-travel.md
- concepts/persistence.md
- concepts/memory.md
@@ -287,7 +301,10 @@ nav:
- High Level:
- High Level: concepts#high-level
- concepts/langgraph_platform.md
- concepts/platform_architecture.md
- concepts/scalability_and_resilience.md
- concepts/deployment_options.md
- concepts/bring_your_own_cloud.md
- concepts/plans.md
- concepts/template_applications.md
- Components:
@@ -311,6 +328,7 @@ nav:
- concepts/langgraph_self_hosted_data_plane.md
- concepts/langgraph_self_hosted_control_plane.md
- concepts/langgraph_standalone_container.md
- concepts/self_hosted.md
- Tutorials:
- tutorials/index.md
- Quick Start:
@@ -387,6 +405,7 @@ nav:
- LangGraph Academy Course: https://academy.langchain.com/courses/intro-to-langgraph
- API reference:
- reference/index.md
- Library:
- Graphs: reference/graphs.md
- Checkpointing: reference/checkpoints.md
@@ -497,13 +516,8 @@ extra:
Thanks for your feedback! Please help us improve this page by adding to the discussion below.
validation:
# https://www.mkdocs.org/user-guide/configuration/
# We're `ignoring` nav.omitted_files because we are going to rely
# on files being properly links to from the index pages of:
# - tutorials
# - concepts
# - how-tos
# - reference
omitted_files: ignore
# We are still raising for omitted files because they determine the breadcrumbs for pages.
omitted_files: warn
absolute_links: warn
unrecognized_links: warn
# TODO: figure out how to enable 'warn' for this
+457 -69
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@ python = "^3.10"
aiohappyeyeballs = "2.4.3"
hub = "^3.0.1"
xxhash = "^3.5.0"
black = "^25.1.0"
[tool.poetry.group.docs.dependencies]
langgraph = { path = "../libs/langgraph/", develop = true }
+121
View File
@@ -0,0 +1,121 @@
{#- Template for Python functions.
This template renders a Python function or method.
Context:
function (griffe.Function): The function to render.
root (bool): Whether this is the root object, injected with `:::` in a Markdown page.
heading_level (int): The HTML heading level to use.
config (dict): The configuration options.
-#}
{% block logs scoped %}
{{ log.debug("Rendering " + function.path) }}
{% endblock logs %}
{% import "language"|get_template as lang with context %}
{#- Language module providing the `t` translation method. -#}
<div class="doc doc-object doc-function">
{% with obj = function, html_id = function.path %}
{% if root %}
{% set show_full_path = config.show_root_full_path %}
{% set root_members = True %}
{% elif root_members %}
{% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %}
{% set root_members = False %}
{% else %}
{% set show_full_path = config.show_object_full_path %}
{% endif %}
{% set function_name = function.path if show_full_path else function.name %}
{#- Brief or full function name depending on configuration. -#}
{% set symbol_type = "method" if function.parent.is_class else "function" %}
{#- Symbol type: method when parent is a class, function otherwise. -#}
{% if not root or config.show_root_heading %}
{% filter heading(
heading_level,
role="function",
id=html_id,
class="doc doc-heading",
toc_label=(('<code class="doc-symbol doc-symbol-toc doc-symbol-' + symbol_type + '"></code>&nbsp;')|safe if config.show_symbol_type_toc else '') + function.name,
) %}
{% block heading scoped %}
{% if config.show_symbol_type_heading %}<code class="doc-symbol doc-symbol-heading doc-symbol-{{ symbol_type }}"></code>{% endif %}
{% if config.separate_signature %}
<span class="doc doc-object-name doc-function-name">{{ config.heading if config.heading and root else function_name }}</span>
{% else %}
{%+ filter highlight(language="python", inline=True) %}
{{ function_name }}{% include "signature"|get_template with context %}
{% endfilter %}
{% endif %}
{% endblock heading %}
{% block labels scoped %}
{% with labels = function.labels %}
{% include "labels"|get_template with context %}
{% endwith %}
{% endblock labels %}
{% endfilter %}
{% block signature scoped %}
{#- Signature block.
This block renders only the main signature and deliberately omits the overloads.
-#}
{% if config.separate_signature %}
{% filter format_signature(function, config.line_length, crossrefs=config.signature_crossrefs) %}
{{ function.name }}
{% endfilter %}
{% endif %}
{% endblock signature %}
{% else %}
{% if config.show_root_toc_entry %}
{% filter heading(
heading_level,
role="function",
id=html_id,
toc_label=(('<code class="doc-symbol doc-symbol-toc doc-symbol-' + symbol_type + '"></code>&nbsp;')|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else function.name),
hidden=True,
) %}
{% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
<div class="doc doc-contents {% if root %}first{% endif %}">
{% block contents scoped %}
{#- Contents block.
This block renders the functions docstring and source.
-#}
{% block docstring scoped %}
{% with docstring_sections = function.docstring.parsed %}
{% include "docstring"|get_template with context %}
{% endwith %}
{% endblock docstring %}
{% block source scoped %}
{% if config.show_source and function.source %}
<details class="quote">
<summary>{{ lang.t("Source code in") }} <code>
{%- if function.relative_filepath.is_absolute() -%}
{{ function.relative_package_filepath }}
{%- else -%}
{{ function.relative_filepath }}
{%- endif -%}
</code></summary>
{{ function.source|highlight(language="python", linestart=function.lineno or 0, linenums=True) }}
</details>
{% endif %}
{% endblock source %}
{% endblock contents %}
</div>
{% endwith %}
</div>
@@ -1,5 +1,6 @@
import asyncio
import threading
import warnings
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
@@ -150,7 +151,7 @@ def _dump_blobs(
checkpoint_ns: str,
values: dict[str, Any],
versions: ChannelVersions,
) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
) -> list[tuple[str, str, str, str, Optional[bytes]]]:
if not versions:
return []
@@ -188,6 +189,12 @@ class ShallowPostgresSaver(BasePostgresSaver):
pipe: Optional[Pipeline] = None,
serde: Optional[SerializerProtocol] = None,
) -> None:
warnings.warn(
"ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
"Use PostgresSaver instead, and invoke the graph with `graph.invoke(..., checkpoint_during=False)`.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(serde=serde)
if isinstance(conn, ConnectionPool) and pipe is not None:
raise ValueError(
@@ -528,6 +535,12 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
pipe: Optional[AsyncPipeline] = None,
serde: Optional[SerializerProtocol] = None,
) -> None:
warnings.warn(
"AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
"Use AsyncPostgresSaver instead, and invoke the graph with `await graph.ainvoke(..., checkpoint_during=False)`.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(serde=serde)
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
raise ValueError(
@@ -38,6 +38,8 @@ class InMemorySaver(
Only use `InMemorySaver` for debugging or testing purposes.
For production use cases we recommend installing [langgraph-checkpoint-postgres](https://pypi.org/project/langgraph-checkpoint-postgres/) and using `PostgresSaver` / `AsyncPostgresSaver`.
If you are using the LangGraph Platform, no checkpointer needs to be specified. The correct managed checkpointer will be used automatically.
Args:
serde (Optional[SerializerProtocol]): The serializer to use for serializing and deserializing checkpoints. Defaults to None.
+4 -26
View File
@@ -274,23 +274,13 @@ def _build(
tag: str,
passthrough: Sequence[str] = (),
):
base_image = base_image or (
"langchain/langgraphjs-api"
if config_json.get("node_version")
else "langchain/langgraph-api"
)
# pull latest images
if pull:
runner.run(
subp_exec(
"docker",
"pull",
(
f"{base_image}:{config_json['node_version']}"
if config_json.get("node_version")
else f"{base_image}:{config_json['python_version']}"
),
langgraph_cli.config.docker_tag(config_json, base_image),
verbose=True,
)
)
@@ -450,11 +440,7 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
dockerfile, additional_contexts = langgraph_cli.config.config_to_docker(
config,
config_json,
(
"langchain/langgraphjs-api"
if config_json.get("node_version")
else "langchain/langgraph-api"
),
None,
)
with open(str(save_path), "w", encoding="utf-8") as f:
f.write(dockerfile)
@@ -719,11 +705,7 @@ def prepare_args_and_stdin(
config_path,
config,
watch=watch,
base_image=(
"langchain/langgraphjs-api"
if config.get("node_version")
else "langchain/langgraph-api"
),
base_image=langgraph_cli.config.default_base_image(config),
)
return args, stdin
@@ -750,11 +732,7 @@ def prepare(
subp_exec(
"docker",
"pull",
(
f"langchain/langgraphjs-api:{config_json['node_version']}"
if config_json.get("node_version")
else f"langchain/langgraph-api:{config_json['python_version']}"
),
langgraph_cli.config.docker_tag(config_json),
verbose=verbose,
)
)
+140 -85
View File
@@ -8,7 +8,10 @@ from typing import Any, Literal, NamedTuple, Optional, TypedDict, Union
import click
MIN_NODE_VERSION = "20"
DEFAULT_NODE_VERSION = "20"
MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11"
class TTLConfig(TypedDict, total=False):
@@ -406,6 +409,18 @@ class Config(TypedDict, total=False):
"""
PIP_CLEANUP_LINES = """# -- Ensure user deps didn't inadvertently overwrite langgraph-api
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir --no-deps -e /api
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
# -- Removing pip from the final image ~<:===~~~ --
RUN pip uninstall -y pip setuptools wheel && \
rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && \
find /usr/local/bin -name "pip*" -delete
# -- End of pip removal --"""
def _parse_version(version_str: str) -> tuple[int, int]:
"""Parse a version string into a tuple of (major, minor)."""
try:
@@ -428,38 +443,54 @@ def _parse_node_version(version_str: str) -> int:
) from None
def _is_node_graph(spec: Union[str, dict]) -> bool:
"""Check if a graph is a Node.js graph based on the file extension."""
if isinstance(spec, dict):
spec = spec.get("path")
file_path = spec.split(":")[0]
file_ext = os.path.splitext(file_path)[1]
return file_ext in [
".ts",
".mts",
".cts",
".js",
".mjs",
".cjs",
]
def validate_config(config: Config) -> Config:
"""Validate a configuration dictionary."""
config = (
{
"node_version": config.get("node_version"),
"dockerfile_lines": config.get("dockerfile_lines", []),
"dependencies": config.get("dependencies", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
}
if config.get("node_version")
else {
"python_version": config.get("python_version", "3.11"),
"pip_config_file": config.get("pip_config_file"),
"dockerfile_lines": config.get("dockerfile_lines", []),
"dependencies": config.get("dependencies", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
}
graphs = config.get("graphs", {})
some_node = any(_is_node_graph(spec) for spec in graphs.values())
some_python = any(not _is_node_graph(spec) for spec in graphs.values())
node_version = config.get(
"node_version", DEFAULT_NODE_VERSION if some_node else None
)
python_version = config.get(
"python_version", DEFAULT_PYTHON_VERSION if some_python else None
)
config = {
"node_version": node_version,
"python_version": python_version,
"pip_config_file": config.get("pip_config_file"),
"dependencies": config.get("dependencies", []),
"dockerfile_lines": config.get("dockerfile_lines", []),
"graphs": config.get("graphs", {}),
"env": config.get("env", {}),
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"checkpointer": config.get("checkpointer"),
"ui": config.get("ui"),
"ui_config": config.get("ui_config"),
}
if config.get("node_version"):
node_version = config["node_version"]
@@ -1073,26 +1104,11 @@ ADD {relpath} /deps/{name}
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
)
ui_inst_str: str = ""
install_node_str: str = ""
if config.get("ui") and local_deps.working_dir:
install_node_str = "RUN /storage/install-node.sh"
ui_inst: list[str] = []
ui_inst.append(f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'")
if config.get("ui_config"):
ui_inst.append(
f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'"
)
ui_inst.append(
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
)
ui_inst_str = f"""# -- Installing UI dependencies --
{os.linesep.join(ui_inst)}
# -- End of UI dependencies install --"""
install_node_str: str = (
"RUN /storage/install-node.sh"
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
else ""
)
installs = f"{os.linesep}{os.linesep}".join(
filter(
@@ -1124,8 +1140,24 @@ ADD {relpath} /deps/{name}
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
graphs = config["graphs"]
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'")
if (ui := config.get("ui")) is not None:
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
if (ui_config := config.get("ui_config")) is not None:
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
js_inst_str: str = ""
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
js_inst_str = os.linesep.join(
[
"# -- Installing JS dependencies --",
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
"# -- End of JS dependencies install --",
]
)
docker_file_contents = [
f"FROM {base_image}:{config['python_version']}",
@@ -1139,7 +1171,9 @@ ADD {relpath} /deps/{name}
"# -- End of local dependencies install --",
os.linesep.join(env_vars),
"",
ui_inst_str,
js_inst_str,
"",
PIP_CLEANUP_LINES, # Add pip cleanup after all installations are complete
"",
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
]
@@ -1162,51 +1196,70 @@ def node_config_to_docker(
) -> tuple[str, dict[str, str]]:
faux_path = f"/deps/{config_path.parent.name}"
install_cmd = _get_node_pm_install_cmd(config_path, config)
store_config = config.get("store")
env_additional_config = (
""
if not store_config
else f"""
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
"""
)
env_vars: list[str] = []
if (store_config := config.get("store")) is not None:
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
if (auth_config := config.get("auth")) is not None:
env_additional_config += f"""
ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'
"""
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
if (http_config := config.get("http")) is not None:
env_additional_config += f"""
ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'
"""
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
if (checkpointer_config := config.get("checkpointer")) is not None:
env_additional_config += f"""
ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'
"""
env_vars.append(
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
)
return (
f"""FROM {base_image}:{config['node_version']}
if ui := config.get("ui"):
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
{os.linesep.join(config["dockerfile_lines"])}
if ui_config := config.get("ui_config"):
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
ADD . {faux_path}
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
RUN cd {faux_path} && {install_cmd}
{env_additional_config}
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'" if config.get("ui") else ""}
{f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'" if config.get("ui_config") else ""}
docker_file_contents = [
f"FROM {base_image}:{config['node_version']}",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
f"ADD . {faux_path}",
"",
f"RUN cd {faux_path} && {install_cmd}",
"",
os.linesep.join(env_vars),
"",
f"WORKDIR {faux_path}",
"",
'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts',
]
WORKDIR {faux_path}
return os.linesep.join(docker_file_contents), {}
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""",
{},
)
def default_base_image(config: Config) -> str:
if config.get("node_version") and not config.get("python_version"):
return "langchain/langgraphjs-api"
return "langchain/langgraph-api"
def docker_tag(config: Config, base_image: Optional[str] = None) -> str:
base_image = base_image or default_base_image(config)
if config.get("node_version") and not config.get("python_version"):
return f"{base_image}:{config['node_version']}"
return f"{base_image}:{config['python_version']}"
def config_to_docker(
config_path: pathlib.Path, config: Config, base_image: str
config_path: pathlib.Path, config: Config, base_image: Optional[str] = None
) -> tuple[str, dict[str, str]]:
if config.get("node_version"):
base_image = base_image or default_base_image(config)
if config.get("node_version") and not config.get("python_version"):
return node_config_to_docker(config_path, config, base_image)
return python_config_to_docker(config_path, config, base_image)
@@ -1215,9 +1268,11 @@ def config_to_docker(
def config_to_compose(
config_path: pathlib.Path,
config: Config,
base_image: str,
base_image: Optional[str] = None,
watch: bool = False,
) -> str:
base_image = base_image or default_base_image(config)
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
env_vars_str = "\n".join(f' {k}: "{v}"' for k, v in env_vars)
env_file_str = (
+23 -14
View File
@@ -44,21 +44,30 @@ class Progress:
sys.stdout.flush()
def __enter__(self) -> Callable[[str], None]:
self.thread = threading.Thread(target=self.spinner_task)
self.thread.start()
if sys.stdout.isatty():
self.thread = threading.Thread(target=self.spinner_task)
self.thread.start()
def set_message(message):
self.message = message
if not message:
self.thread.join()
def set_message(message):
self.message = message
if not message:
self.thread.join()
return set_message
return set_message
else:
def set_message(message):
sys.stderr.write(message + "\n")
sys.stderr.flush()
return set_message
def __exit__(self, exception, value, tb):
self.message = ""
try:
self.thread.join()
finally:
del self.thread
if exception is not None:
return False
if sys.stdout.isatty():
self.message = ""
try:
self.thread.join()
finally:
del self.thread
if exception is not None:
return False
+33 -20
View File
@@ -20,7 +20,7 @@ description = "High level compatibility layer for multiple asynchronous event lo
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"},
{file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"},
@@ -59,7 +59,7 @@ description = "Python package for providing Mozilla's CA Bundle."
optional = true
python-versions = ">=3.6"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
{file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
@@ -405,7 +405,7 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
@@ -418,7 +418,7 @@ description = "A minimal low-level HTTP client."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"},
{file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"},
@@ -441,7 +441,7 @@ description = "The next generation HTTP client."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -467,7 +467,7 @@ description = "Internationalized Domain Names in Applications (IDNA)"
optional = true
python-versions = ">=3.6"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@@ -614,27 +614,27 @@ langgraph-sdk = ">=0.1.42,<0.2.0"
[[package]]
name = "langgraph-api"
version = "0.0.42"
version = "0.1.0"
description = ""
optional = true
python-versions = "<4.0,>=3.11.0"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_api-0.0.42-py3-none-any.whl", hash = "sha256:19f69d9d39efde60a9bd3eeae6dc7dbe8d04b1b6fccf4ddf51d7e6b7187cc6ea"},
{file = "langgraph_api-0.0.42.tar.gz", hash = "sha256:a0a18545c73f9703d5d5907fc030e4a0acb79d1e6b79d4e38b3cac2bfb470e97"},
{file = "langgraph_api-0.1.0-py3-none-any.whl", hash = "sha256:93eb369849d5ea0dd6076f0a36fe0a9669415c46e95fcb28ccf30c73e13e5e01"},
{file = "langgraph_api-0.1.0.tar.gz", hash = "sha256:d02201d34172f3020af8f21c4560142b302474cee4a27f4335fbd950ab22f22f"},
]
[package.dependencies]
blockbuster = ">=1.5.24,<2.0.0"
cloudpickle = ">=3.0.0,<4.0.0"
cryptography = ">=43.0.3,<44.0.0"
cryptography = ">=42.0.0,<45.0"
httpx = ">=0.25.0"
jsonschema-rs = ">=0.20.0,<0.30"
langchain-core = ">=0.2.38,<0.4.0"
langgraph = ">=0.2.56,<0.4.0"
langgraph-checkpoint = ">=2.0.23,<3.0"
langgraph-sdk = ">=0.1.59,<0.2.0"
langgraph-sdk = ">=0.1.61,<0.2.0"
langsmith = ">=0.1.63,<0.4.0"
orjson = ">=3.9.7"
pyjwt = ">=2.9.0,<3.0.0"
@@ -679,17 +679,30 @@ files = [
langchain-core = ">=0.2.43,<0.3.0 || >0.3.0,<0.3.1 || >0.3.1,<0.3.2 || >0.3.2,<0.3.3 || >0.3.3,<0.3.4 || >0.3.4,<0.3.5 || >0.3.5,<0.3.6 || >0.3.6,<0.3.7 || >0.3.7,<0.3.8 || >0.3.8,<0.3.9 || >0.3.9,<0.3.10 || >0.3.10,<0.3.11 || >0.3.11,<0.3.12 || >0.3.12,<0.3.13 || >0.3.13,<0.3.14 || >0.3.14,<0.3.15 || >0.3.15,<0.3.16 || >0.3.16,<0.3.17 || >0.3.17,<0.3.18 || >0.3.18,<0.3.19 || >0.3.19,<0.3.20 || >0.3.20,<0.3.21 || >0.3.21,<0.3.22 || >0.3.22,<0.4.0"
langgraph-checkpoint = ">=2.0.10,<3.0.0"
[[package]]
name = "langgraph-runtime-inmem"
version = "0.0.1"
description = "Inmem implementation for the LangGraph API server."
optional = true
python-versions = ">=3.11"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_runtime_inmem-0.0.1-py3-none-any.whl", hash = "sha256:a25ec8e3219f2fd60450de38412d24c83fbf0b2521c13871cc26ce9a68ead496"},
{file = "langgraph_runtime_inmem-0.0.1.tar.gz", hash = "sha256:144bf5217efec4969f7f9c5e8279d6914cc5133d4b1a19e466b4966fa05f00c5"},
]
[[package]]
name = "langgraph-sdk"
version = "0.1.60"
version = "0.1.61"
description = "SDK for interacting with LangGraph API"
optional = true
python-versions = "<4.0.0,>=3.9.0"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "langgraph_sdk-0.1.60-py3-none-any.whl", hash = "sha256:953df85b0a6cc3a106f0496ce8f950a65d88b3ba8198c3b4bb58a54469b256a9"},
{file = "langgraph_sdk-0.1.60.tar.gz", hash = "sha256:7857a4a2a20a6a4c9934d1e7b5145eda92e3bc7286121813de2464d071050f88"},
{file = "langgraph_sdk-0.1.61-py3-none-any.whl", hash = "sha256:f2d774b12497c428862993090622d51e0dbc3f53e0cee3d74a13c7495d835cc6"},
{file = "langgraph_sdk-0.1.61.tar.gz", hash = "sha256:87dd1f07ab82da8875ac343268ece8bf5414632017ebc9d1cef4b523962fd601"},
]
[package.dependencies]
@@ -851,7 +864,7 @@ description = "Fast, correct Python JSON library supporting dataclasses, datetim
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04"},
{file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8"},
@@ -1414,7 +1427,7 @@ description = "Sniff out which async library your code is running under"
optional = true
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
markers = "python_version >= \"3.11\""
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -1550,7 +1563,7 @@ files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
markers = {main = "python_version >= \"3.11\" and extra == \"inmem\""}
markers = {main = "python_version >= \"3.11\" and (python_version < \"3.13\" or extra == \"inmem\")"}
[[package]]
name = "urllib3"
@@ -1834,9 +1847,9 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\
cffi = ["cffi (>=1.11)"]
[extras]
inmem = ["langgraph-api", "python-dotenv"]
inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
[metadata]
lock-version = "2.1"
python-versions = "^3.9.0,<4.0"
content-hash = "4a45d739795019ae00e18ba8b0d366209deca9c5a5e65e9f387e5cf1d5aef187"
content-hash = "afc2f8776b4b6144bd1197df49ba34089889e2a1110b8470d8f1b212e0b08380"
+5 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.1.89"
version = "0.2.4"
description = "CLI for interacting with LangGraph API"
authors = []
license = "MIT"
@@ -14,7 +14,9 @@ langgraph = "langgraph_cli.cli:cli"
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
click = "^8.1.7"
langgraph-api = { version = ">=0.0.42,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-api = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-runtime-inmem = { version = ">=0.0.1,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-sdk = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
python-dotenv = { version = ">=0.8.0", optional = true }
[tool.poetry.group.dev.dependencies]
@@ -28,7 +30,7 @@ mypy = "^1.10.0"
msgspec = "^0.19.0"
[tool.poetry.extras]
inmem = ["langgraph-api", "python-dotenv"]
inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
[tool.pytest.ini_options]
# --strict-markers will raise errors on unknown marks.
+5 -2
View File
@@ -2,13 +2,14 @@ import json
import pathlib
import shutil
import tempfile
import textwrap
from contextlib import contextmanager
from pathlib import Path
from click.testing import CliRunner
from langgraph_cli.cli import cli, prepare_args_and_stdin
from langgraph_cli.config import Config, validate_config
from langgraph_cli.config import PIP_CLEANUP_LINES, Config, validate_config
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
from langgraph_cli.util import clean_empty_lines
@@ -143,6 +144,7 @@ services:
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/cli
develop:
@@ -176,8 +178,9 @@ def test_dockerfile_command_basic() -> None:
"""Test the 'dockerfile' command with basic configuration."""
runner = CliRunner()
config_content = {
"node_version": "20", # Add any other necessary configuration fields
"python_version": "3.11",
"graphs": {"agent": "agent.py:graph"},
"dependencies": ["."],
}
with temporary_config_folder(config_content) as temp_dir:
+161 -37
View File
@@ -2,11 +2,13 @@ import json
import os
import pathlib
import tempfile
import textwrap
import click
import pytest
from langgraph_cli.config import (
PIP_CLEANUP_LINES,
config_to_compose,
config_to_docker,
validate_config,
@@ -25,8 +27,10 @@ def test_validate_config():
"agent": "./agent.py:graph",
},
}
actual_config = validate_config(expected_config)
expected_config = {
"python_version": "3.11",
"node_version": None,
"pip_config_file": None,
"dockerfile_lines": [],
"env": {},
@@ -38,13 +42,13 @@ def test_validate_config():
"ui_config": None,
**expected_config,
}
actual_config = validate_config(expected_config)
assert actual_config == expected_config
# full config
env = ".env"
expected_config = {
"python_version": "3.12",
"node_version": None,
"pip_config_file": "pipconfig.txt",
"dockerfile_lines": ["ARG meow"],
"dependencies": [".", "langchain"],
@@ -67,16 +71,12 @@ def test_validate_config():
# check wrong python version raises
with pytest.raises(click.UsageError):
validate_config(
{
"python_version": "3.9",
}
)
validate_config({"python_version": "3.9"})
# check missing dependencies key raises
with pytest.raises(click.UsageError):
validate_config(
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}},
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}
)
# check missing graphs key raises
@@ -194,6 +194,58 @@ def test_validate_config_file():
validate_config_file(config_path)
def test_validate_config_multiplatform():
# default node
config = validate_config(
{"dependencies": ["."], "graphs": {"js": "./js.mts:graph"}}
)
assert config["node_version"] == "20"
assert config["python_version"] is None
# default multiplatform
config = validate_config(
{
"node_version": "22",
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"python": "./python.py:graph", "js": "./js.mts:graph"},
}
)
assert config["node_version"] == "22"
assert config["python_version"] == "3.12"
# default multiplatform (full infer)
graphs = {"python": "./python.py:graph", "js": "./js.mts:graph"}
config = validate_config({"dependencies": ["."], "graphs": graphs})
assert config["node_version"] == "20"
assert config["python_version"] == "3.11"
# default multiplatform (partial node)
config = validate_config(
{"node_version": "22", "dependencies": ["."], "graphs": graphs}
)
assert config["node_version"] == "22"
assert config["python_version"] == "3.11"
# default multiplatform (partial python)
config = validate_config(
{"python_version": "3.12", "dependencies": ["."], "graphs": graphs}
)
assert config["node_version"] == "20"
assert config["python_version"] == "3.12"
# no known extension (assumes python)
config = validate_config(
{
"dependencies": ["./local", "./shared_utils"],
"graphs": {"agent": "local.workflow:graph"},
"env": ".env",
}
)
assert config["node_version"] is None
assert config["python_version"] == "3.11"
# config_to_docker
def test_config_to_docker_simple():
graphs = {"agent": "./agent.py:graph"}
@@ -208,7 +260,7 @@ def test_config_to_docker_simple():
),
"langchain/langgraph-api",
)
expected_docker_stdin = """\
expected_docker_stdin = f"""\
FROM langchain/langgraph-api:3.11
# -- Installing local requirements --
COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
@@ -242,8 +294,9 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGGRAPH_HTTP='{"app": "/deps/examples/my_app.py:app"}'
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
ENV LANGGRAPH_HTTP='{{"app": "/deps/examples/my_app.py:app"}}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{PIP_CLEANUP_LINES}
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
@@ -263,7 +316,8 @@ def test_config_to_docker_outside_path():
validate_config({"dependencies": [".", ".."], "graphs": graphs}),
"langchain/langgraph-api",
)
expected_docker_stdin = """\
expected_docker_stdin = (
"""\
FROM langchain/langgraph-api:3.11
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
@@ -291,8 +345,12 @@ RUN set -ex && \\
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
"""
+ PIP_CLEANUP_LINES
+ """
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
)
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {
"__outer_tests": str(pathlib.Path(__file__).parent.parent.absolute()),
@@ -312,7 +370,8 @@ def test_config_to_docker_pipconfig():
),
"langchain/langgraph-api",
)
expected_docker_stdin = """\
expected_docker_stdin = (
"""\
FROM langchain/langgraph-api:3.11
ADD pipconfig.txt /pipconfig.txt
# -- Adding non-package dependency unit_tests --
@@ -330,8 +389,12 @@ RUN set -ex && \\
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
"""
+ PIP_CLEANUP_LINES
+ """
WORKDIR /deps/__outer_unit_tests/unit_tests\
"""
)
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -368,7 +431,7 @@ def test_config_to_docker_local_deps():
),
"langchain/langgraph-api-custom",
)
expected_docker_stdin = """\
expected_docker_stdin = f"""\
FROM langchain/langgraph-api-custom:3.11
# -- Adding non-package dependency graphs --
ADD ./graphs /deps/__outer_graphs/src
@@ -384,7 +447,8 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'\
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
{PIP_CLEANUP_LINES}\
"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -411,7 +475,8 @@ dependencies = ["langchain"]"""
"langchain/langgraph-api",
)
os.remove(pyproject_path)
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
expected_docker_stdin = (
"""FROM langchain/langgraph-api:3.11
# -- Adding local package . --
ADD . /deps/unit_tests
# -- End of local package . --
@@ -419,7 +484,12 @@ ADD . /deps/unit_tests
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
WORKDIR /deps/unit_tests"""
"""
+ PIP_CLEANUP_LINES
+ "\n"
+ "WORKDIR /deps/unit_tests"
""
)
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -439,7 +509,7 @@ def test_config_to_docker_end_to_end():
),
"langchain/langgraph-api",
)
expected_docker_stdin = """FROM langchain/langgraph-api:3.12
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.12
ARG meow
ARG foo
ADD pipconfig.txt /pipconfig.txt
@@ -458,7 +528,8 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'"""
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
{PIP_CLEANUP_LINES}"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
@@ -473,6 +544,7 @@ def test_config_to_docker_nodejs():
"node_version": "20",
"graphs": graphs,
"dockerfile_lines": ["ARG meow", "ARG foo"],
"auth": {"path": "./graphs/auth.mts:auth"},
"ui": {"agent": "./graphs/agent.ui.jsx"},
"ui_config": {"shared": ["nuqs"]},
}
@@ -484,9 +556,10 @@ ARG meow
ARG foo
ADD . /deps/unit_tests
RUN cd /deps/unit_tests && npm i
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
@@ -509,7 +582,7 @@ def test_config_to_docker_gen_ui_python():
"langchain/langgraph-api",
)
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
@@ -525,12 +598,55 @@ RUN set -ex && \\
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
# -- Installing UI dependencies --
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}'
ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
# -- Installing JS dependencies --
ENV NODE_VERSION=20
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
# -- End of UI dependencies install --
# -- End of JS dependencies install --
{PIP_CLEANUP_LINES}
WORKDIR /deps/__outer_unit_tests/unit_tests"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
assert additional_contexts == {}
def test_config_to_docker_multiplatform():
graphs = {
"python": "./multiplatform/python.py:graph",
"js": "./multiplatform/js.mts:graph",
}
actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{"node_version": "22", "dependencies": ["."], "graphs": graphs}
),
"langchain/langgraph-api",
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/__outer_unit_tests/unit_tests
RUN set -ex && \\
for line in '[project]' \\
'name = "unit_tests"' \\
'version = "0.1"' \\
'[tool.setuptools.package-data]' \\
'"*" = ["**/*"]'; do \\
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
done
# -- End of non-package dependency unit_tests --
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"python": "/deps/__outer_unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/__outer_unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
# -- Installing JS dependencies --
ENV NODE_VERSION=22
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{PIP_CLEANUP_LINES}
WORKDIR /deps/__outer_unit_tests/unit_tests"""
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
@@ -540,8 +656,8 @@ WORKDIR /deps/__outer_unit_tests/unit_tests"""
# config_to_compose
def test_config_to_compose_simple_config():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
# Create a properly indented version of PIP_CLEANUP_LINES for compose files
expected_compose_stdin = f"""
pull_policy: build
build:
context: .
@@ -561,7 +677,8 @@ def test_config_to_compose_simple_config():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
@@ -569,12 +686,15 @@ def test_config_to_compose_simple_config():
validate_config({"dependencies": ["."], "graphs": graphs}),
"langchain/langgraph-api",
)
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
assert (
clean_empty_lines(actual_compose_stdin).strip()
== expected_compose_stdin.strip()
)
def test_config_to_compose_env_vars():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """ OPENAI_API_KEY: "key"
expected_compose_stdin = f""" OPENAI_API_KEY: "key"
pull_policy: build
build:
@@ -595,7 +715,8 @@ def test_config_to_compose_env_vars():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
openai_api_key = "key"
@@ -615,7 +736,7 @@ def test_config_to_compose_env_vars():
def test_config_to_compose_env_file():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
expected_compose_stdin = f"""\
env_file: .env
pull_policy: build
build:
@@ -636,7 +757,8 @@ def test_config_to_compose_env_file():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/__outer_unit_tests/unit_tests
"""
actual_compose_stdin = config_to_compose(
@@ -649,7 +771,7 @@ def test_config_to_compose_env_file():
def test_config_to_compose_watch():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
expected_compose_stdin = f"""\
pull_policy: build
build:
@@ -670,7 +792,8 @@ def test_config_to_compose_watch():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
@@ -692,7 +815,7 @@ def test_config_to_compose_watch():
def test_config_to_compose_end_to_end():
# test all of the above + langgraph API path
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = """\
expected_compose_stdin = f"""\
env_file: .env
pull_policy: build
build:
@@ -713,7 +836,8 @@ def test_config_to_compose_end_to_end():
# -- Installing all local dependencies --
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
WORKDIR /deps/__outer_unit_tests/unit_tests
develop:
+3
View File
@@ -40,6 +40,9 @@ agent.invoke(
)
```
> [!TIP]
> Check out [this guide](https://langchain-ai.github.io/langgraph/tutorials/workflows/) that walks through implementing common patterns (workflows and agents) in LangGraph.
## Why use LangGraph?
LangGraph is built for developers who want to build powerful, adaptable AI agents. Developers choose LangGraph for:
+101
View File
@@ -9,6 +9,7 @@ from bench.fanout_to_subgraph import fanout_to_subgraph, fanout_to_subgraph_sync
from bench.pydantic_state import pydantic_state
from bench.react_agent import react_agent
from bench.sequential import create_sequential
from bench.wide_dict import wide_dict
from bench.wide_state import wide_state
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph
@@ -25,6 +26,7 @@ async def arun(graph: Pregel, input: dict):
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
]
)
@@ -41,6 +43,7 @@ async def arun_first_event_latency(graph: Pregel, input: dict) -> None:
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
try:
@@ -60,6 +63,7 @@ def run(graph: Pregel, input: dict):
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
]
)
@@ -76,6 +80,7 @@ def run_first_event_latency(graph: Pregel, input: dict) -> None:
"configurable": {"thread_id": str(uuid4())},
"recursion_limit": 1000000000,
},
checkpoint_during=False,
)
try:
@@ -251,6 +256,102 @@ benchmarks = (
]
},
),
(
"wide_dict_25x300",
wide_dict(300).compile(checkpointer=None),
wide_dict(300).compile(checkpointer=None),
{
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(5)
}
for i in range(5)
}
]
},
),
(
"wide_dict_25x300_checkpoint",
wide_dict(300).compile(checkpointer=MemorySaver()),
wide_dict(300).compile(checkpointer=MemorySaver()),
{
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(5)
}
for i in range(5)
}
]
},
),
(
"wide_dict_15x600",
wide_dict(600).compile(checkpointer=None),
wide_dict(600).compile(checkpointer=None),
{
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(5)
}
for i in range(3)
}
]
},
),
(
"wide_dict_15x600_checkpoint",
wide_dict(600).compile(checkpointer=MemorySaver()),
wide_dict(600).compile(checkpointer=MemorySaver()),
{
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(5)
}
for i in range(3)
}
]
},
),
(
"wide_dict_9x1200",
wide_dict(1200).compile(checkpointer=None),
wide_dict(1200).compile(checkpointer=None),
{
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(3)
}
for i in range(3)
}
]
},
),
(
"wide_dict_9x1200_checkpoint",
wide_dict(1200).compile(checkpointer=MemorySaver()),
wide_dict(1200).compile(checkpointer=MemorySaver()),
{
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(3)
}
for i in range(3)
}
]
},
),
(
"sequential_10",
create_sequential(10).compile(),
@@ -106,6 +106,7 @@ def fanout_to_subgraph_sync() -> StateGraph:
if __name__ == "__main__":
import asyncio
import random
import time
import uvloop
@@ -123,4 +124,7 @@ if __name__ == "__main__":
len([c async for c in graph.astream(input, config=config)])
uvloop.install()
start = time.time()
asyncio.run(run())
end = time.time()
print(f"Time taken: {end - start:.4f} seconds")
+153
View File
@@ -0,0 +1,153 @@
import operator
from functools import partial
from random import choice
from typing import Annotated, Optional, Sequence
from typing_extensions import TypedDict
from langgraph.constants import END, START
from langgraph.graph.state import StateGraph
def wide_dict(n: int) -> StateGraph:
class State(TypedDict):
messages: Annotated[list, operator.add]
trigger_events: Annotated[list, operator.add]
"""The external events that are converted by the graph."""
primary_issue_medium: Annotated[str, lambda x, y: y or x]
autoresponse: Annotated[Optional[dict], lambda _, y: y] # Always overwrite
issue: Annotated[dict | None, lambda x, y: y if y else x]
relevant_rules: Optional[list[dict]]
"""SOPs fetched from the rulebook that are relevant to the current conversation."""
memory_docs: Optional[list[dict]]
"""Memory docs fetched from the memory service that are relevant to the current conversation."""
categorizations: Annotated[list[dict], operator.add]
"""The issue categorizations auto-generated by the AI."""
responses: Annotated[list[dict], operator.add]
"""The draft responses recommended by the AI."""
user_info: Annotated[Optional[dict], lambda x, y: y if y is not None else x]
"""The current user state (by email)."""
crm_info: Annotated[Optional[dict], lambda x, y: y if y is not None else x]
"""The CRM information for organization the current user is from."""
email_thread_id: Annotated[
Optional[str], lambda x, y: y if y is not None else x
]
"""The current email thread ID."""
slack_participants: Annotated[dict, operator.or_]
"""The growing list of current slack participants."""
bot_id: Optional[str]
"""The ID of the bot user in the slack channel."""
notified_assignees: Annotated[dict, operator.or_]
list_fields = {
"messages",
"trigger_events",
"categorizations",
"responses",
"memory_docs",
"relevant_rules",
}
dict_fields = {
"user_info",
"crm_info",
"slack_participants",
"notified_assignees",
"autoresponse",
"issue",
}
def read_write(read: str, write: Sequence[str], input: State) -> dict:
val = input.get(read)
val = {val: val} if isinstance(val, str) else val
val_single = val[-1] if isinstance(val, list) else val
val_list = val if isinstance(val, list) else [val]
return {
k: val_list
if k in list_fields
else val_single
if k in dict_fields
else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
for k in write
}
builder = StateGraph(State)
builder.add_edge(START, "one")
builder.add_node(
"one",
partial(read_write, "messages", ["trigger_events", "primary_issue_medium"]),
)
builder.add_edge("one", "two")
builder.add_node(
"two",
partial(read_write, "trigger_events", ["autoresponse", "issue"]),
)
builder.add_edge("two", "three")
builder.add_edge("two", "four")
builder.add_node(
"three",
partial(read_write, "autoresponse", ["relevant_rules"]),
)
builder.add_node(
"four",
partial(
read_write,
"trigger_events",
["categorizations", "responses", "memory_docs"],
),
)
builder.add_node(
"five",
partial(
read_write,
"categorizations",
[
"user_info",
"crm_info",
"email_thread_id",
"slack_participants",
"bot_id",
"notified_assignees",
],
),
)
builder.add_edge(["three", "four"], "five")
builder.add_edge("five", "six")
builder.add_node(
"six",
partial(read_write, "responses", ["messages"]),
)
builder.add_conditional_edges(
"six", lambda state: END if len(state["messages"]) > n else "one"
)
return builder
if __name__ == "__main__":
import asyncio
import uvloop
from langgraph.checkpoint.memory import MemorySaver
graph = wide_dict(1000).compile(checkpointer=MemorySaver())
input = {
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(50)
}
for i in range(50)
}
]
}
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 20000000000}
async def run():
async for c in graph.astream(input, config=config):
print(c.keys())
uvloop.install()
asyncio.run(run())
+24 -1
View File
@@ -1,6 +1,7 @@
import operator
from dataclasses import dataclass, field
from functools import partial
from random import choice
from typing import Annotated, Optional, Sequence
from langgraph.constants import END, START
@@ -49,12 +50,34 @@ def wide_state(n: int) -> StateGraph:
"""The ID of the bot user in the slack channel."""
notified_assignees: Annotated[dict, operator.or_] = field(default_factory=dict)
list_fields = {
"messages",
"trigger_events",
"categorizations",
"responses",
"memory_docs",
"relevant_rules",
}
dict_fields = {
"user_info",
"crm_info",
"slack_participants",
"notified_assignees",
"autoresponse",
"issue",
}
def read_write(read: str, write: Sequence[str], input: State) -> dict:
val = getattr(input, read)
val = {val: val} if isinstance(val, str) else val
val_single = val[-1] if isinstance(val, list) else val
val_list = val if isinstance(val, list) else [val]
return {
k: val_list if isinstance(getattr(input, k), list) else val_single
k: val_list
if k in list_fields
else val_single
if k in dict_fields
else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
for k in write
}
+4
View File
@@ -69,6 +69,8 @@ CONFIG_KEY_ENSURE_LATEST = sys.intern("__pregel_ensure_latest")
# (for distributed mode)
CONFIG_KEY_DELEGATE = sys.intern("__pregel_delegate")
# holds a boolean indicating whether to delegate subgraphs (for distributed mode)
CONFIG_KEY_THREAD_ID = sys.intern("thread_id")
# holds the thread ID for the current invocation
CONFIG_KEY_CHECKPOINT_MAP = sys.intern("checkpoint_map")
# holds a mapping of checkpoint_ns -> checkpoint_id for parent graphs
CONFIG_KEY_CHECKPOINT_ID = sys.intern("checkpoint_id")
@@ -83,6 +85,8 @@ CONFIG_KEY_PREVIOUS = sys.intern("__pregel_previous")
# holds the previous return value from a stateful Pregel graph.
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
# holds a function that receives tasks from runner, executes them and returns results
CONFIG_KEY_CHECKPOINT_DURING = sys.intern("__pregel_checkpoint_during")
# holds a boolean indicating whether to checkpoint during the run (or only at the end)
# --- Other constants ---
PUSH = sys.intern("__pregel_push")
+10 -6
View File
@@ -9,6 +9,7 @@ from typing import (
Callable,
Generic,
Optional,
Sequence,
TypeVar,
Union,
get_args,
@@ -19,7 +20,7 @@ from typing import (
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.constants import END, PREVIOUS, START, TAG_HIDDEN
from langgraph.constants import END, PREVIOUS, START
from langgraph.pregel import Pregel
from langgraph.pregel.call import (
P,
@@ -38,7 +39,7 @@ from langgraph.types import _DC_KWARGS, RetryPolicy, StreamMode
def task(
*,
name: Optional[str] = None,
retry: Optional[RetryPolicy] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
) -> Callable[
[Union[Callable[P, Awaitable[T]], Callable[P, T]]],
Callable[P, SyncAsyncFuture[T]],
@@ -55,7 +56,7 @@ def task(
__func_or_none__: Optional[Union[Callable[P, Awaitable[T]], Callable[P, T]]] = None,
*,
name: Optional[str] = None,
retry: Optional[RetryPolicy] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
) -> Union[
Callable[
[Union[Callable[P, Awaitable[T]], Callable[P, T]]],
@@ -119,6 +120,10 @@ def task(
await add_one.ainvoke([1, 2, 3]) # Returns [2, 3, 4]
```
"""
if isinstance(retry, RetryPolicy):
retry_policies: Optional[Sequence[RetryPolicy]] = (retry,)
else:
retry_policies = retry
def decorator(
func: Union[Callable[P, Awaitable[T]], Callable[P, T]],
@@ -137,7 +142,7 @@ def task(
# handle regular functions / partials / callable classes, etc.
func.__name__ = name
call_func = functools.partial(call, func, retry=retry)
call_func = functools.partial(call, func, retry=retry_policies)
object.__setattr__(call_func, "_is_pregel_task", True)
return functools.update_wrapper(call_func, func)
@@ -429,8 +434,7 @@ class entrypoint:
[
ChannelWriteEntry(END, mapper=_pluck_return_value),
ChannelWriteEntry(PREVIOUS, mapper=_pluck_save_value),
],
tags=[TAG_HIDDEN],
]
)
],
)
+1 -2
View File
@@ -1,4 +1,3 @@
import asyncio
from inspect import (
isfunction,
ismethod,
@@ -178,7 +177,7 @@ class Branch(NamedTuple):
],
) -> Runnable:
if reader:
value = await asyncio.to_thread(reader, config)
value = reader(config)
# passthrough additional keys from node to branch
# only doable when using dict states
if (
+3 -8
View File
@@ -366,16 +366,14 @@ class CompiledGraph(Pregel):
self.nodes[key] = (
PregelNode(channels=[], triggers=[], metadata=node.metadata)
| node.runnable
| ChannelWrite([ChannelWriteEntry(key)], tags=[TAG_HIDDEN])
| ChannelWrite([ChannelWriteEntry(key)])
)
cast(list[str], self.stream_channels).append(key)
def attach_edge(self, start: str, end: str) -> None:
if end == END:
# publish to end channel
self.nodes[start].writers.append(
ChannelWrite([ChannelWriteEntry(END)], tags=[TAG_HIDDEN])
)
self.nodes[start].writers.append(ChannelWrite([ChannelWriteEntry(END)]))
else:
# subscribe to start channel
self.nodes[end].triggers.append(start)
@@ -393,10 +391,7 @@ class CompiledGraph(Pregel):
)
for p in packets
]
return ChannelWrite(
cast(Sequence[Union[ChannelWriteEntry, Send]], writes),
tags=[TAG_HIDDEN],
)
return ChannelWrite(cast(Sequence[Union[ChannelWriteEntry, Send]], writes))
# add hidden start node
if start == START and start not in self.nodes:
+160 -50
View File
@@ -1,3 +1,4 @@
import functools
import logging
import weakref
from inspect import isclass
@@ -16,6 +17,8 @@ from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated
__all__ = ["SchemaCoercionMapper"]
logger = logging.getLogger(__name__)
@@ -25,54 +28,60 @@ _cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]]
class SchemaCoercionMapper:
"""Lightweight coercion of *dict* → *BaseModel* instances."""
def __new__(
cls,
schema: Type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
) -> "SchemaCoercionMapper":
if schema not in _cache:
_cache[schema] = {}
if max_depth in _cache[schema]:
return _cache[schema][max_depth]
by_depth = _cache.setdefault(schema, {})
if max_depth in by_depth:
return by_depth[max_depth]
inst = super().__new__(cls)
_cache[schema][max_depth] = inst
by_depth[max_depth] = inst
return inst
def __init__(
self,
schema: Type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
):
if hasattr(self, "_inited"):
) -> None:
if hasattr(self, "_initialised"):
return
self._inited = True
self._initialised = True
self.schema = schema
self.max_depth = max_depth
self.type_hints = (
type_hints
if type_hints is not None
else get_type_hints(schema, localns={schema.__name__: schema})
)
self.max_depth = max_depth
if issubclass(schema, BaseModel):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.model_fields.items()
}
self._construct: Callable[..., Any] = schema.model_construct
elif issubclass(schema, BaseModelV1):
if issubclass(schema, BaseModelV1):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.__fields__.items()
}
self._construct = schema.construct
elif issubclass(schema, BaseModel):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.model_fields.items()
}
self._construct: Callable[..., Any] = schema.model_construct # type: ignore
else:
raise TypeError("Schema is neither valid Pydantic v1 nor v2 model.")
self._field_coercers: Optional[dict[str, Callable[[Any, Any], Any]]] = None
raise TypeError("Schema is neither a Pydantic v1 nor v2 model.")
self._field_coercers: Optional[dict[str, Callable[[Any, int], Any]]] = None
def __call__(self, input_data: Any, depth: Optional[int] = None) -> Any:
return self.coerce(input_data, depth)
@@ -82,45 +91,51 @@ class SchemaCoercionMapper:
depth = self.max_depth
if not isinstance(input_data, dict) or depth <= 0:
return input_data
processed = {}
if self._field_coercers is None:
self._field_coercers = {
n: self._build_coercer(t, depth - 1) for n, t in self._fields.items()
}
processed: dict[str, Any] = {}
for k, v in input_data.items():
fn = self._field_coercers.get(k)
processed[k] = fn(v, depth - 1) if fn else v
return self._construct(**processed)
def _build_coercer(
self, field_type: Any, depth: int, throw: bool = False
self, field_type: Any, depth: int, *, throw: bool = False
) -> Callable[[Any, Any], Any]:
if depth == 0:
return self._passthrough
origin = get_origin(field_type)
if (field_type in _IDENTITY_TYPES) or (origin in _IDENTITY_TYPES):
return self._passthrough
if origin is Annotated:
real_type, *_ = get_args(field_type)
sub = self._build_coercer(real_type, depth - 1)
return lambda v, d: sub(v, d)
if isclass(field_type):
# This is needed bcs. of issubclass issues on older versions of python
is_class_ = True
try:
is_base_model = issubclass(field_type, BaseModel)
is_bm_v2 = issubclass(field_type, BaseModel)
except TypeError:
# python < 3.11 issue.
is_class_ = False
is_base_model = False
is_bm_v2 = False
if is_bm_v2 or (is_class_ and issubclass(field_type, BaseModelV1)):
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
if is_base_model:
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
if is_class_ and issubclass(field_type, BaseModelV1):
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
if origin is list or field_type is list:
if origin is list:
args = get_args(field_type)
if len(args) != 1:
return lambda v, d: v
return self._passthrough
sub = self._build_coercer(args[0], depth - 1)
def list_coercer(v: Any, d: Any) -> Any:
@@ -129,15 +144,21 @@ class SchemaCoercionMapper:
return [sub(x, d - 1) for x in v]
return list_coercer
if origin is set or field_type is set:
args = get_args(field_type)
if len(args) != 1:
return lambda v, d: v
sub = self._build_coercer(args[0], depth - 1)
if len(args) > 1:
return self._passthrough
elif len(args) == 1:
sub = self._build_coercer(args[0], depth - 1)
else:
sub = None # type: ignore
def set_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple, set)):
return v
if sub is None:
return set(v)
return {sub(x, d - 1) for x in v}
return set_coercer
@@ -165,20 +186,19 @@ class SchemaCoercionMapper:
return dict_coercer
if origin is tuple:
targs = get_args(field_type)
if not targs:
return lambda v, d: v
subs = [self._build_coercer(a, depth - 1) for a in targs]
elem_types = get_args(field_type)
if not elem_types:
return self._passthrough
subs = [self._build_coercer(t, depth - 1) for t in elem_types]
return lambda v, d: (
tuple(
subs[i](v[i] if i < len(v) else None, d - 1)
for i in range(len(subs))
)
if isinstance(v, (list, tuple))
else v
)
def tuple_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple)):
return v
out = []
for i, sp in enumerate(subs):
out.append(sp(v[i] if i < len(v) else None, d - 1))
return tuple(out)
return tuple_coercer
if origin is Union:
uargs = get_args(field_type)
subs, none_in_union = [], False
@@ -204,7 +224,97 @@ class SchemaCoercionMapper:
return v
return union_coercer
return self._passthrough
def _passthrough(self, v: Any, d: Any) -> Any:
adapter_fn = _get_adapter(field_type)
return lambda v, _d: adapter_fn(v)
@staticmethod
def _passthrough(v: Any, _d: Any) -> Any: # noqa: D401
return v
_adapter_cache: dict[Any, Callable[[Any], Any]] = {}
_IDENTITY_TYPES: tuple[type[Any], ...] = (
int,
float,
str,
bool,
bytes,
bytearray,
complex,
memoryview,
type(None),
)
try:
# Pydantic v2.
from pydantic import TypeAdapter
try:
import pydantic.v1.types as v1_types_
from pydantic.v1 import parse_obj_as
v1_types = tuple(
v for k, v in vars(v1_types_).items() if k in v1_types_.__all__
)
except ImportError:
v1_types = ()
def parse_obj_as(tp: Any, v: Any) -> Any: # type: ignore
return v
try:
from pydantic.v1 import parse_obj_as
from pydantic.v1.main import create_model
except ImportError:
create_model = None # type: ignore
def _get_v1_parser(tp: Any) -> Any:
if create_model is not None:
try:
parser = create_model(
f"ParsingModel[{tp}]",
__root__=(tp, ...),
)
return lambda v: parser(__root__=v).__root__ # type: ignore
except RuntimeError:
return lambda v: v
return lambda v: parse_obj_as(tp, v)
@functools.lru_cache(maxsize=2048)
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
if tp in v1_types:
return _get_v1_parser(tp)
try:
return TypeAdapter(
tp, config={"arbitrary_types_allowed": True}
).validate_python
except TypeError:
# Delayed classes like ConstrainedList
return _get_v1_parser(tp)
except ImportError:
# Pydantic V1
from pydantic.v1.main import create_model
@functools.lru_cache(maxsize=2048)
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
try:
parser = create_model(
f"ParsingModel[{tp}]",
__root__=(tp, ...),
)
return lambda v: parser(__root__=v).__root__ # type: ignore
except RuntimeError:
return lambda v: v
def _get_adapter(tp: Any) -> Callable[[Any], Any]:
try:
return _adapter_cache[tp]
except KeyError:
fn = _adapter_for(tp)
_adapter_cache[tp] = fn
return fn
+94 -137
View File
@@ -43,8 +43,8 @@ from langgraph.constants import (
MISSING,
NS_END,
NS_SEP,
SELF,
TAG_HIDDEN,
TASKS,
)
from langgraph.errors import (
ErrorCode,
@@ -77,9 +77,9 @@ from langgraph.pregel.write import (
)
from langgraph.store.base import BaseStore
from langgraph.types import All, Checkpointer, Command, RetryPolicy
from langgraph.utils.fields import get_field_default
from langgraph.utils.fields import get_field_default, get_update_as_tuples
from langgraph.utils.pydantic import create_model
from langgraph.utils.runnable import RunnableCallable, RunnableLike, coerce_to_runnable
from langgraph.utils.runnable import RunnableLike, coerce_to_runnable
logger = logging.getLogger(__name__)
@@ -109,7 +109,7 @@ class StateNodeSpec(NamedTuple):
runnable: Runnable
metadata: Optional[dict[str, Any]]
input: Type[Any]
retry_policy: Optional[RetryPolicy]
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]]
ends: Optional[Union[tuple[str, ...], dict[str, str]]] = EMPTY_SEQ
@@ -251,7 +251,7 @@ class StateGraph(Graph):
*,
metadata: Optional[dict[str, Any]] = None,
input: Optional[Type[Any]] = None,
retry: Optional[RetryPolicy] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
) -> Self:
"""Adds a new node to the state graph.
@@ -276,7 +276,7 @@ class StateGraph(Graph):
*,
metadata: Optional[dict[str, Any]] = None,
input: Optional[Type[Any]] = None,
retry: Optional[RetryPolicy] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
) -> Self:
"""Adds a new node to the state graph.
@@ -300,7 +300,7 @@ class StateGraph(Graph):
*,
metadata: Optional[dict[str, Any]] = None,
input: Optional[Type[Any]] = None,
retry: Optional[RetryPolicy] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
) -> Self:
"""Adds a new node to the state graph.
@@ -312,7 +312,8 @@ class StateGraph(Graph):
action (Optional[RunnableLike]): The action associated with the node. (default: None)
metadata (Optional[dict[str, Any]]): The metadata associated with the node. (default: None)
input (Optional[Type[Any]]): The input schema for the node. (default: the graph's input schema)
retry (Optional[RetryPolicy]): The policy for retrying the node. (default: None)
retry (Optional[Union[RetryPolicy, Sequence[RetryPolicy]]]): The policy for retrying the node. (default: None)
If a sequence is provided, the first matching policy will be applied.
destinations (Optional[Union[dict[str, str], tuple[str, ...]]]): Destinations that indicate where a node can route to.
This is useful for edgeless graphs with nodes that return `Command` objects.
If a dict is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
@@ -638,6 +639,7 @@ class StateGraph(Graph):
compiled = CompiledStateGraph(
builder=self,
schema_to_mapper={},
config_type=self.config_schema,
input_model=(
self.input
@@ -669,10 +671,6 @@ class StateGraph(Graph):
for key, node in self.nodes.items():
compiled.attach_node(key, node)
compiled.attach_branch(START, SELF, CONTROL_BRANCH, with_reader=False)
for key, node in self.nodes.items():
compiled.attach_branch(key, SELF, CONTROL_BRANCH, with_reader=False)
for start, end in self.edges:
compiled.attach_edge(start, end)
@@ -688,6 +686,16 @@ class StateGraph(Graph):
class CompiledStateGraph(CompiledGraph):
builder: StateGraph
schema_to_mapper: dict[Type[Any], Optional[Callable[[Any], Any]]]
def __init__(
self,
*,
schema_to_mapper: dict[Type[Any], Optional[Callable[[Any], Any]]],
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.schema_to_mapper = schema_to_mapper
def get_input_schema(
self, config: Optional[RunnableConfig] = None
@@ -723,28 +731,6 @@ class CompiledStateGraph(CompiledGraph):
if is_writable_managed_value(v)
]
def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
if isinstance(input, Command):
if input.graph == Command.PARENT:
return ()
return input._update_as_tuples()
elif (
isinstance(input, (list, tuple))
and input
and any(isinstance(i, Command) for i in input)
):
updates: list[tuple[str, Any]] = []
for i in input:
if isinstance(i, Command):
if i.graph == Command.PARENT:
continue
updates.extend(i._update_as_tuples())
else:
updates.append(("__root__", i))
return updates
elif input is not None:
return [("__root__", input)]
def _get_updates(
input: Union[None, dict, Any],
) -> Optional[Sequence[tuple[str, Any]]]:
@@ -775,32 +761,7 @@ class CompiledStateGraph(CompiledGraph):
updates.extend(_get_updates(i) or ())
return updates
elif (t := type(input)) and get_type_hints(t):
# Pydantic v2
if isinstance(input, BaseModel):
keep: Optional[set[str]] = input.model_fields_set
defaults = {k: v.default for k, v in input.model_fields.items()}
# Pydantic v1
elif isinstance(input, BaseModelV1):
keep = input.__fields_set__
defaults = {k: v.default for k, v in t.__fields__.items()}
else:
keep = None
defaults = {}
# NOTE: This behavior for Pydantic is somewhat inelegant,
# but we keep around for backwards compatibility
# if input is a Pydantic model, only update values
# that are different from the default values or in the keep set
return [
(k, value)
for k in output_keys
if (value := getattr(input, k, MISSING)) is not MISSING
and (
value is not None
or defaults.get(k, MISSING) is not None
or (keep is not None and k in keep)
)
]
return get_update_as_tuples(input, output_keys)
else:
msg = create_error_message(
message=f"Expected dict, got {input}",
@@ -813,6 +774,7 @@ class CompiledStateGraph(CompiledGraph):
ChannelWriteTupleEntry(
mapper=_get_root if output_keys == ["__root__"] else _get_updates
),
ChannelWriteTupleEntry(mapper=_control_branch),
)
# add node and output channel
@@ -821,12 +783,21 @@ class CompiledStateGraph(CompiledGraph):
tags=[TAG_HIDDEN],
triggers=[START],
channels=[START],
writers=[ChannelWrite(write_entries, tags=[TAG_HIDDEN])],
writers=[ChannelWrite(write_entries)],
)
elif node is not None:
input_schema = node.input if node else self.builder.schema
input_values = {k: k for k in self.builder.schemas[input_schema]}
is_single_input = len(input_values) == 1 and "__root__" in input_values
if input_schema in self.schema_to_mapper:
mapper = self.schema_to_mapper[input_schema]
else:
mapper = _pick_mapper(
list(input_values),
input_schema,
self.builder.type_hints[input_schema],
)
self.schema_to_mapper[input_schema] = mapper
branch_channel = CHANNEL_BRANCH_TO.format(key)
self.channels[branch_channel] = EphemeralValue(Any, guard=False)
@@ -835,13 +806,9 @@ class CompiledStateGraph(CompiledGraph):
# read state keys and managed values
channels=(list(input_values) if is_single_input else input_values),
# coerce state dict to schema class (eg. pydantic model)
mapper=_pick_mapper(
list(input_values),
input_schema,
self.builder.type_hints[input_schema],
),
mapper=mapper,
# publish to state keys
writers=[ChannelWrite(write_entries, tags=[TAG_HIDDEN])],
writers=[ChannelWrite(write_entries)],
metadata=node.metadata,
retry_policy=node.retry_policy,
bound=node.runnable,
@@ -867,9 +834,7 @@ class CompiledStateGraph(CompiledGraph):
# publish to channel
for start in starts:
self.nodes[start].writers.append(
ChannelWrite(
(ChannelWriteEntry(channel_name, start),), tags=[TAG_HIDDEN]
)
ChannelWrite((ChannelWriteEntry(channel_name, start),))
)
def attach_branch(
@@ -900,19 +865,33 @@ class CompiledStateGraph(CompiledGraph):
config, cast(Sequence[Union[Send, ChannelWriteEntry]], writes)
)
schema = branch.input_schema or (
self.builder.nodes[start].input
if start in self.builder.nodes
else self.builder.schema
)
if with_reader:
# get schema
schema = branch.input_schema or (
self.builder.nodes[start].input
if start in self.builder.nodes
else self.builder.schema
)
channels = list(self.builder.schemas[schema])
# get mapper
if schema in self.schema_to_mapper:
mapper = self.schema_to_mapper[schema]
else:
mapper = _pick_mapper(channels, schema, self.builder.type_hints[schema])
self.schema_to_mapper[schema] = mapper
# create reader
reader: Optional[Callable[[RunnableConfig], Any]] = partial(
ChannelRead.do_read,
select=channels[0] if channels == ["__root__"] else channels,
fresh=True,
# coerce state dict to schema class (eg. pydantic model)
mapper=mapper,
)
else:
reader = None
# attach branch publisher
self.nodes[start].writers.append(
branch.run(
branch_writer,
_get_state_reader(self.builder, schema) if with_reader else None,
)
)
self.nodes[start].writers.append(branch.run(branch_writer, reader))
# attach then subscriber
if branch.then and branch.then != END:
@@ -927,9 +906,7 @@ class CompiledStateGraph(CompiledGraph):
for end in ends:
if end != END:
self.nodes[end].writers.append(
ChannelWrite(
[ChannelWriteEntry(channel_name, end)], tags=[TAG_HIDDEN]
)
ChannelWrite((ChannelWriteEntry(channel_name, end),))
)
def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
@@ -1037,20 +1014,6 @@ class CompiledStateGraph(CompiledGraph):
seen[INTERRUPT].pop(k, MISSING)
def _get_state_reader(
builder: StateGraph, schema: Type[Any]
) -> Callable[[RunnableConfig], Any]:
state_keys = list(builder.channels)
select = list(builder.schemas[schema])
return partial(
ChannelRead.do_read,
select=select[0] if select == ["__root__"] else select,
fresh=True,
# coerce state dict to schema class (eg. pydantic model)
mapper=_pick_mapper(state_keys, schema, builder.type_hints[schema]),
)
def _pick_mapper(
state_keys: Sequence[str], schema: Type[Any], type_hints: Optional[dict[str, Any]]
) -> Optional[Callable[[Any], Any]]:
@@ -1060,7 +1023,7 @@ def _pick_mapper(
if issubclass(schema, dict):
return None
if issubclass(schema, (BaseModel, BaseModelV1)):
return SchemaCoercionMapper(schema, type_hints)
return SchemaCoercionMapper(schema, type_hints=type_hints)
return partial(_coerce_state, schema)
@@ -1068,9 +1031,9 @@ def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
return schema(**input)
def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
def _control_branch(value: Any) -> Sequence[tuple[str, Any]]:
if isinstance(value, Send):
return [value]
return ((TASKS, value),)
commands: list[Command] = []
if isinstance(value, Command):
commands.append(value)
@@ -1078,51 +1041,45 @@ def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
for cmd in value:
if isinstance(cmd, Command):
commands.append(cmd)
rtn: list[Union[str, Send]] = []
rtn: list[tuple[str, Any]] = []
for command in commands:
if command.graph == Command.PARENT:
raise ParentCommand(command)
if isinstance(command.goto, Send):
rtn.append(command.goto)
rtn.append((TASKS, command.goto))
elif isinstance(command.goto, str):
rtn.append(command.goto)
rtn.append((CHANNEL_BRANCH_TO.format(command.goto), None))
else:
rtn.extend(command.goto)
rtn.extend(
(TASKS, go)
if isinstance(go, Send)
else (CHANNEL_BRANCH_TO.format(go), None)
for go in command.goto
)
return rtn
async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]:
if isinstance(value, Send):
return [value]
commands: list[Command] = []
if isinstance(value, Command):
commands.append(value)
elif isinstance(value, (list, tuple)):
for cmd in value:
if isinstance(cmd, Command):
commands.append(cmd)
rtn: list[Union[str, Send]] = []
for command in commands:
if command.graph == Command.PARENT:
raise ParentCommand(command)
if isinstance(command.goto, Send):
rtn.append(command.goto)
elif isinstance(command.goto, str):
rtn.append(command.goto)
else:
rtn.extend(command.goto)
return rtn
CONTROL_BRANCH_PATH = RunnableCallable(
_control_branch,
_acontrol_branch,
tags=[TAG_HIDDEN],
trace=False,
recurse=False,
func_accepts_config=False,
)
CONTROL_BRANCH = Branch(CONTROL_BRANCH_PATH, None)
def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
if isinstance(input, Command):
if input.graph == Command.PARENT:
return ()
return input._update_as_tuples()
elif (
isinstance(input, (list, tuple))
and input
and any(isinstance(i, Command) for i in input)
):
updates: list[tuple[str, Any]] = []
for i in input:
if isinstance(i, Command):
if i.graph == Command.PARENT:
continue
updates.extend(i._update_as_tuples())
else:
updates.append(("__root__", i))
return updates
elif input is not None:
return [("__root__", input)]
def _get_channels(
+71 -17
View File
@@ -39,7 +39,6 @@ from langchain_core.runnables.utils import (
ConfigurableFieldSpec,
get_unique_config_specs,
)
from langchain_core.tracers._streaming import _StreamingCallbackHandler
from pydantic import BaseModel
from typing_extensions import Self
@@ -54,6 +53,7 @@ from langgraph.checkpoint.base import (
)
from langgraph.constants import (
CONF,
CONFIG_KEY_CHECKPOINT_DURING,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_CHECKPOINTER,
@@ -66,6 +66,7 @@ from langgraph.constants import (
CONFIG_KEY_STREAM,
CONFIG_KEY_STREAM_WRITER,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_THREAD_ID,
END,
ERROR,
INPUT,
@@ -125,6 +126,11 @@ from langgraph.utils.fields import get_enhanced_type_hints
from langgraph.utils.pydantic import create_model, is_supported_by_pydantic
from langgraph.utils.queue import AsyncQueue, SyncQueue # type: ignore[attr-defined]
try:
from langchain_core.tracers._streaming import _StreamingCallbackHandler
except ImportError:
_StreamingCallbackHandler = None # type: ignore
WriteValue = Union[Callable[[Input], Output], Any]
@@ -493,8 +499,8 @@ class Pregel(PregelProtocol):
store: Optional[BaseStore] = None
"""Memory store to use for SharedValues. Defaults to None."""
retry_policy: Optional[RetryPolicy] = None
"""Retry policy to use when running tasks. Set to None to disable."""
retry_policy: Optional[Sequence[RetryPolicy]] = None
"""Retry policies to use when running tasks. Set to None to disable."""
config_type: Optional[Type[Any]] = None
@@ -523,7 +529,7 @@ class Pregel(PregelProtocol):
debug: Optional[bool] = None,
checkpointer: Optional[BaseCheckpointSaver] = None,
store: Optional[BaseStore] = None,
retry_policy: Optional[RetryPolicy] = None,
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
config_type: Optional[Type[Any]] = None,
input_model: Optional[Type[BaseModel]] = None,
config: Optional[RunnableConfig] = None,
@@ -543,7 +549,10 @@ class Pregel(PregelProtocol):
self.debug = debug if debug is not None else get_debug()
self.checkpointer = checkpointer
self.store = store
self.retry_policy = retry_policy
if isinstance(retry_policy, RetryPolicy):
self.retry_policy: Sequence[RetryPolicy] = (retry_policy,)
else:
self.retry_policy = retry_policy
self.config_type = config_type
self.input_model = input_model
self.config = config
@@ -1036,6 +1045,9 @@ class Pregel(PregelProtocol):
config = merge_configs(
config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
)
thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
if not isinstance(thread_id, str):
config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
saved = checkpointer.get_tuple(config)
return self._prepare_state_snapshot(
@@ -1075,6 +1087,9 @@ class Pregel(PregelProtocol):
config = merge_configs(
config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
)
thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
if not isinstance(thread_id, str):
config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
saved = await checkpointer.aget_tuple(config)
return await self._aprepare_state_snapshot(
@@ -1120,7 +1135,12 @@ class Pregel(PregelProtocol):
config = merge_configs(
self.config,
config,
{CONF: {CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns}},
{
CONF: {
CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
}
},
)
# eagerly consume list() to avoid holding up the db cursor
for checkpoint_tuple in list(
@@ -1167,7 +1187,12 @@ class Pregel(PregelProtocol):
config = merge_configs(
self.config,
config,
{CONF: {CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns}},
{
CONF: {
CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
}
},
)
# eagerly consume list() to avoid holding up the db cursor
for checkpoint_tuple in [
@@ -1587,7 +1612,9 @@ class Pregel(PregelProtocol):
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
current_config = config
current_config = patch_configurable(
config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
)
for superstep in supersteps:
current_config = perform_superstep(current_config, superstep)
return current_config
@@ -1997,7 +2024,9 @@ class Pregel(PregelProtocol):
await checkpointer.aput_writes(next_config, push_writes, task_id)
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
current_config = config
current_config = patch_configurable(
config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
)
for superstep in supersteps:
current_config = await aperform_superstep(current_config, superstep)
return current_config
@@ -2094,6 +2123,7 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
subgraphs: bool = False,
) -> Iterator[Union[dict[str, Any], Any]]:
@@ -2115,6 +2145,7 @@ class Pregel(PregelProtocol):
output_keys: The keys to stream, defaults to all non-context channels.
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
debug: Whether to print debug information during execution, defaults to False.
subgraphs: Whether to stream subgraphs, defaults to False.
@@ -2276,6 +2307,9 @@ class Pregel(PregelProtocol):
config[CONF][CONFIG_KEY_STREAM_WRITER] = lambda c: stream.put(
((), "custom", c)
)
# set checkpointing mode for subgraphs
if checkpoint_during is not None:
config[CONF][CONFIG_KEY_CHECKPOINT_DURING] = checkpoint_during
with SyncPregelLoop(
input,
input_model=self.input_model,
@@ -2291,6 +2325,9 @@ class Pregel(PregelProtocol):
interrupt_after=interrupt_after_,
manager=run_manager,
debug=debug,
checkpoint_during=checkpoint_during
if checkpoint_during is not None
else config[CONF].get(CONFIG_KEY_CHECKPOINT_DURING, True),
trigger_to_nodes=self.trigger_to_nodes,
migrate_checkpoint=self._migrate_checkpoint,
) as loop:
@@ -2373,6 +2410,7 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
subgraphs: bool = False,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
@@ -2394,6 +2432,7 @@ class Pregel(PregelProtocol):
output_keys: The keys to stream, defaults to all non-context channels.
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
debug: Whether to print debug information during execution, defaults to False.
subgraphs: Whether to stream subgraphs, defaults to False.
@@ -2529,13 +2568,18 @@ class Pregel(PregelProtocol):
run_id=config.get("run_id"),
)
# if running from astream_log() run each proc with streaming
do_stream = next(
(
cast(_StreamingCallbackHandler, h)
for h in run_manager.handlers
if isinstance(h, _StreamingCallbackHandler)
),
None,
do_stream = (
next(
(
True
for h in run_manager.handlers
if isinstance(h, _StreamingCallbackHandler)
and not isinstance(h, StreamMessagesHandler)
),
False,
)
if _StreamingCallbackHandler is not None
else False
)
try:
# assign defaults
@@ -2571,6 +2615,9 @@ class Pregel(PregelProtocol):
stream.put_nowait, ((), "custom", c)
)
)
# set checkpointing mode for subgraphs
if checkpoint_during is not None:
config[CONF][CONFIG_KEY_CHECKPOINT_DURING] = checkpoint_during
async with AsyncPregelLoop(
input,
input_model=self.input_model,
@@ -2586,6 +2633,9 @@ class Pregel(PregelProtocol):
interrupt_after=interrupt_after_,
manager=run_manager,
debug=debug,
checkpoint_during=checkpoint_during
if checkpoint_during is not None
else config[CONF].get(CONFIG_KEY_CHECKPOINT_DURING, True),
trigger_to_nodes=self.trigger_to_nodes,
migrate_checkpoint=self._migrate_checkpoint,
) as loop:
@@ -2596,7 +2646,7 @@ class Pregel(PregelProtocol):
),
put_writes=weakref.WeakMethod(loop.put_writes),
schedule_task=weakref.WeakMethod(loop.accept_push),
use_astream=do_stream is not None,
use_astream=do_stream,
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
)
# enable subgraph streaming
@@ -2661,6 +2711,7 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
@@ -2692,6 +2743,7 @@ class Pregel(PregelProtocol):
output_keys=output_keys,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
checkpoint_during=checkpoint_during,
debug=debug,
**kwargs,
):
@@ -2713,6 +2765,7 @@ class Pregel(PregelProtocol):
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
@@ -2745,6 +2798,7 @@ class Pregel(PregelProtocol):
output_keys=output_keys,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
checkpoint_during=checkpoint_during,
debug=debug,
**kwargs,
):
+43 -32
View File
@@ -3,13 +3,13 @@ import itertools
import sys
import threading
from collections import defaultdict, deque
from copy import copy
from functools import partial
from hashlib import sha1
from typing import (
Any,
Callable,
Iterable,
Iterator,
Literal,
Mapping,
NamedTuple,
@@ -49,6 +49,7 @@ from langgraph.constants import (
EMPTY_SEQ,
ERROR,
INTERRUPT,
MISSING,
NO_WRITES,
NS_END,
NS_SEP,
@@ -63,12 +64,12 @@ from langgraph.constants import (
TASKS,
Send,
)
from langgraph.errors import EmptyChannelError, InvalidUpdateError
from langgraph.errors import InvalidUpdateError
from langgraph.managed.base import ManagedValueMapping
from langgraph.pregel.call import get_runnable_for_task
from langgraph.pregel.io import read_channel, read_channels
from langgraph.pregel.io import read_channels
from langgraph.pregel.log import logger
from langgraph.pregel.read import PregelNode
from langgraph.pregel.read import INPUT_CACHE_KEY_TYPE, PregelNode
from langgraph.store.base import BaseStore
from langgraph.types import (
All,
@@ -115,7 +116,7 @@ class Call:
func: Callable
input: Any
retry: Optional[RetryPolicy]
retry: Optional[Sequence[RetryPolicy]]
callbacks: Callbacks
def __init__(
@@ -123,7 +124,7 @@ class Call:
func: Callable,
input: Any,
*,
retry: Optional[RetryPolicy],
retry: Optional[Sequence[RetryPolicy]],
callbacks: Callbacks,
) -> None:
self.func = func
@@ -423,6 +424,7 @@ def prepare_next_tasks(
are the tasks themselves. This is the union of all PUSH tasks (Sends)
and PULL tasks (nodes triggered by edges).
"""
input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] = {}
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
null_version = checkpoint_null_version(checkpoint)
tasks: list[Union[PregelTask, PregelExecutableTask]] = []
@@ -444,6 +446,7 @@ def prepare_next_tasks(
store=store,
checkpointer=checkpointer,
manager=manager,
input_cache=input_cache,
):
tasks.append(task)
@@ -486,6 +489,7 @@ def prepare_next_tasks(
store=store,
checkpointer=checkpointer,
manager=manager,
input_cache=input_cache,
):
tasks.append(task)
return {t.id: t for t in tasks}
@@ -511,6 +515,7 @@ def prepare_single_task(
store: Optional[BaseStore] = None,
checkpointer: Optional[BaseCheckpointSaver] = None,
manager: Union[None, ParentRunManager, AsyncParentRunManager] = None,
input_cache: Optional[dict[INPUT_CACHE_KEY_TYPE, Any]] = None,
) -> Union[None, PregelTask, PregelExecutableTask]:
"""Prepares a single task for the next Pregel step, given a task path, which
uniquely identifies a PUSH or PULL task within the graph."""
@@ -729,11 +734,15 @@ def prepare_single_task(
):
triggers = tuple(sorted(proc.triggers))
try:
val = next(
_proc_input(proc, managed, channels, for_execution=for_execution)
val = _proc_input(
proc,
managed,
channels,
for_execution=for_execution,
input_cache=input_cache,
)
except StopIteration:
return
if val is MISSING:
return
except Exception as exc:
if SUPPORTS_EXC_NOTES:
exc.add_note(
@@ -926,34 +935,32 @@ def _proc_input(
channels: Mapping[str, BaseChannel],
*,
for_execution: bool,
) -> Iterator[Any]:
input_cache: Optional[dict[INPUT_CACHE_KEY_TYPE, Any]],
) -> Any:
"""Prepare input for a PULL task, based on the process's channels and triggers."""
# if in cache return shallow copy
if input_cache is not None and proc.input_cache_key in input_cache:
return copy(input_cache[proc.input_cache_key])
# If all trigger channels subscribed by this process are not empty
# then invoke the process with the values of all non-empty channels
if isinstance(proc.channels, dict):
try:
val: dict[str, Any] = {}
for k, chan in proc.channels.items():
if chan in proc.triggers:
val[k] = read_channel(channels, chan, catch=False)
elif chan in channels:
try:
val[k] = read_channel(channels, chan, catch=False)
except EmptyChannelError:
continue
else:
val[k] = managed[k]()
except EmptyChannelError:
return
val: dict[str, Any] = {}
for k, chan in proc.channels.items():
if chan in channels:
if channels[chan].is_available():
val[k] = channels[chan].get()
else:
val[k] = managed[k]()
elif isinstance(proc.channels, list):
for chan in proc.channels:
try:
val = read_channel(channels, chan, catch=False)
break
except EmptyChannelError:
pass
if chan in channels:
if channels[chan].is_available():
val = channels[chan].get()
break
else:
val[k] = managed[k]()
else:
return
return MISSING
else:
raise RuntimeError(
"Invalid channels type, expected list or dict, got {proc.channels}"
@@ -963,7 +970,11 @@ def _proc_input(
if for_execution and proc.mapper is not None:
val = proc.mapper(val)
yield val
# Cache the input value
if input_cache is not None:
input_cache[proc.input_cache_key] = val
return val
def _uuid5_str(namespace: bytes, *parts: str) -> str:
+4 -4
View File
@@ -5,12 +5,12 @@ import functools
import inspect
import sys
import types
from typing import Any, Callable, Generator, Generic, Optional, TypeVar, cast
from typing import Any, Callable, Generator, Generic, Optional, Sequence, TypeVar, cast
from langchain_core.runnables import Runnable
from typing_extensions import ParamSpec
from langgraph.constants import CONF, CONFIG_KEY_CALL, RETURN, TAG_HIDDEN
from langgraph.constants import CONF, CONFIG_KEY_CALL, RETURN
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
from langgraph.types import RetryPolicy
from langgraph.utils.config import get_config
@@ -197,7 +197,7 @@ def get_runnable_for_task(func: Callable[..., Any]) -> RunnableSeq:
)
seq = RunnableSeq(
run,
ChannelWrite([ChannelWriteEntry(RETURN)], tags=[TAG_HIDDEN]),
ChannelWrite([ChannelWriteEntry(RETURN)]),
name=name,
trace_inputs=functools.partial(
_explode_args_trace_inputs, inspect.signature(func)
@@ -224,7 +224,7 @@ class SyncAsyncFuture(Generic[T], concurrent.futures.Future[T]):
def call(
func: Callable[P, T],
*args: Any,
retry: Optional[RetryPolicy] = None,
retry: Optional[Sequence[RetryPolicy]] = None,
**kwargs: Any,
) -> SyncAsyncFuture[T]:
config = get_config()
+114 -50
View File
@@ -52,6 +52,7 @@ from langgraph.constants import (
CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_THREAD_ID,
EMPTY_SEQ,
ERROR,
INPUT,
@@ -63,6 +64,7 @@ from langgraph.constants import (
RESUME,
SCHEDULED,
TAG_HIDDEN,
TASKS,
)
from langgraph.errors import (
CheckpointNotLatest,
@@ -155,7 +157,7 @@ class PregelLoop(LoopProtocol):
manager: Union[None, AsyncParentRunManager, ParentRunManager]
interrupt_after: Union[All, Sequence[str]]
interrupt_before: Union[All, Sequence[str]]
checkpoint_every_step: bool
checkpoint_during: bool
debug: bool
checkpointer_get_next_version: GetNextVersion
@@ -180,6 +182,7 @@ class PregelLoop(LoopProtocol):
channels: Mapping[str, BaseChannel]
managed: ManagedValueMapping
checkpoint: Checkpoint
checkpoint_id_saved: str
checkpoint_ns: tuple[str, ...]
checkpoint_config: RunnableConfig
checkpoint_metadata: CheckpointMetadata
@@ -215,7 +218,7 @@ class PregelLoop(LoopProtocol):
debug: bool = False,
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
checkpoint_every_step: bool = True,
checkpoint_during: bool = True,
) -> None:
super().__init__(
step=0,
@@ -241,7 +244,7 @@ class PregelLoop(LoopProtocol):
)
self._migrate_checkpoint = migrate_checkpoint
self.trigger_to_nodes = trigger_to_nodes
self.checkpoint_every_step = checkpoint_every_step
self.checkpoint_during = checkpoint_during
self.debug = debug
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
@@ -283,6 +286,12 @@ class PregelLoop(LoopProtocol):
)
else:
self.checkpoint_config = self.config
if thread_id := self.checkpoint_config[CONF].get(CONFIG_KEY_THREAD_ID):
if not isinstance(thread_id, str):
self.checkpoint_config = patch_configurable(
self.checkpoint_config,
{CONFIG_KEY_THREAD_ID: str(thread_id)},
)
self.checkpoint_ns = (
tuple(cast(str, self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP))
if self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
@@ -294,29 +303,19 @@ class PregelLoop(LoopProtocol):
"""Put writes for a task, to be read by the next tick."""
if not writes:
return
# always checkpoint writes containing Send, as they are fetched from the
# parent checkpoint, not the current one
checkpoint_during = self.checkpoint_during or any(w[0] == TASKS for w in writes)
# deduplicate writes to special channels, last write wins
if all(w[0] in WRITES_IDX_MAP for w in writes):
writes = list({w[0]: w for w in writes}.values())
# remove existing writes for this task
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[0] != task_id
]
# save writes
for c, v in writes:
if (
c in WRITES_IDX_MAP
and (
idx := next(
(
i
for i, w in enumerate(self.checkpoint_pending_writes)
if w[0] == task_id and w[1] == c
),
None,
)
)
is not None
):
self.checkpoint_pending_writes[idx] = (task_id, c, v)
else:
self.checkpoint_pending_writes.append((task_id, c, v))
if self.checkpointer_put_writes is not None:
self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
if checkpoint_during and self.checkpointer_put_writes is not None:
config = patch_configurable(
self.checkpoint_config,
{
@@ -349,6 +348,46 @@ class PregelLoop(LoopProtocol):
if hasattr(self, "tasks"):
self._output_writes(task_id, writes)
def _put_pending_writes(self) -> None:
if self.checkpointer_put_writes is None:
return
if not self.checkpoint_pending_writes:
return
# patch config
config = patch_configurable(
self.checkpoint_config,
{
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
CONFIG_KEY_CHECKPOINT_NS, ""
),
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
)
# group by task id
by_task = defaultdict(list)
for task_id, channel, value in self.checkpoint_pending_writes:
by_task[task_id].append((channel, value))
# submit writes to checkpointer
for task_id, writes in by_task.items():
if self.checkpointer_put_writes_accepts_task_path and hasattr(
self, "tasks"
):
task = self.tasks.get(task_id)
self.submit(
self.checkpointer_put_writes,
config,
writes,
task_id,
task_path_str(task.path) if task else "",
)
else:
self.submit(
self.checkpointer_put_writes,
config,
writes,
task_id,
)
def accept_push(
self, task: PregelExecutableTask, write_idx: int, call: Optional[Call] = None
) -> Optional[PregelExecutableTask]:
@@ -711,32 +750,44 @@ class PregelLoop(LoopProtocol):
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
# assign step and parents
metadata["step"] = self.step
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
# debug flag
if self.debug:
print_step_checkpoint(
metadata,
self.channels,
(
[self.stream_keys]
if isinstance(self.stream_keys, str)
else self.stream_keys
),
)
exiting = metadata is self.checkpoint_metadata
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
# checkpoint already saved
return
if not exiting:
metadata["step"] = self.step
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
self.checkpoint_metadata = metadata
# debug flag
if self.debug:
print_step_checkpoint(
metadata,
self.channels,
(
[self.stream_keys]
if isinstance(self.stream_keys, str)
else self.stream_keys
),
)
self.checkpoint_id_prev = self.checkpoint["id"] if self.step > -1 else None
# do checkpoint?
do_checkpoint = self._checkpointer_put_after_previous is not None and (
exiting or self.checkpoint_during
)
# create new checkpoint
self.checkpoint = create_checkpoint(
self.checkpoint,
self.channels if do_checkpoint else None,
self.step,
id=self.checkpoint["id"] if exiting else None,
)
# bail if no checkpointer
if self._checkpointer_put_after_previous is not None:
if do_checkpoint and self._checkpointer_put_after_previous is not None:
for k, v in self.config["metadata"].items():
if k in EXCLUDED_METADATA_KEYS:
continue
metadata.setdefault(k, v) # type: ignore
# create new checkpoint
self.checkpoint = create_checkpoint(
self.checkpoint, self.channels, self.step
)
self.checkpoint_metadata = metadata
self.prev_checkpoint_config = (
self.checkpoint_config
if CONFIG_KEY_CHECKPOINT_ID in self.checkpoint_config[CONF]
@@ -747,6 +798,8 @@ class PregelLoop(LoopProtocol):
**self.checkpoint_config,
CONF: {
**self.checkpoint_config[CONF],
# this is guaranteed to be set by code above
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint_id_prev,
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
CONFIG_KEY_CHECKPOINT_NS, ""
),
@@ -777,8 +830,9 @@ class PregelLoop(LoopProtocol):
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
}
# increment step
self.step += 1
if not exiting:
# increment step
self.step += 1
def _update_mv(self, key: str, values: Sequence[Any]) -> None:
raise NotImplementedError
@@ -789,6 +843,10 @@ class PregelLoop(LoopProtocol):
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
# persist current checkpoint and writes
if not self.checkpoint_during:
self._put_checkpoint(self.checkpoint_metadata)
self._put_pending_writes()
# suppress interrupt
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
if suppress:
@@ -907,6 +965,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
debug: bool = False,
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
checkpoint_during: bool = True,
) -> None:
super().__init__(
input,
@@ -925,6 +984,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
debug=debug,
migrate_checkpoint=migrate_checkpoint,
trigger_to_nodes=trigger_to_nodes,
checkpoint_during=checkpoint_during,
)
self.stack = ExitStack()
if checkpointer:
@@ -990,20 +1050,21 @@ class SyncPregelLoop(PregelLoop, ContextManager):
saved = None
if saved is None:
saved = CheckpointTuple(
self.config, empty_checkpoint(), {"step": -2}, None, []
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
)
elif self._migrate_checkpoint is not None:
self._migrate_checkpoint(saved.checkpoint)
self.checkpoint_config = {
**self.config,
**self.checkpoint_config,
**saved.config,
CONF: {
CONFIG_KEY_CHECKPOINT_NS: "",
**self.config.get(CONF, {}),
**self.checkpoint_config.get(CONF, {}),
**saved.config.get(CONF, {}),
},
}
self.prev_checkpoint_config = saved.parent_config
self.checkpoint_id_saved = saved.checkpoint["id"]
self.checkpoint = saved.checkpoint
self.checkpoint_metadata = saved.metadata
self.checkpoint_pending_writes = (
@@ -1054,6 +1115,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
debug: bool = False,
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
checkpoint_during: bool = True,
) -> None:
super().__init__(
input,
@@ -1072,6 +1134,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
debug=debug,
migrate_checkpoint=migrate_checkpoint,
trigger_to_nodes=trigger_to_nodes,
checkpoint_during=checkpoint_during,
)
self.stack = AsyncExitStack()
if checkpointer:
@@ -1137,20 +1200,21 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
saved = None
if saved is None:
saved = CheckpointTuple(
self.config, empty_checkpoint(), {"step": -2}, None, []
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
)
elif self._migrate_checkpoint is not None:
self._migrate_checkpoint(saved.checkpoint)
self.checkpoint_config = {
**self.config,
**self.checkpoint_config,
**saved.config,
CONF: {
CONFIG_KEY_CHECKPOINT_NS: "",
**self.config.get(CONF, {}),
**self.checkpoint_config.get(CONF, {}),
**saved.config.get(CONF, {}),
},
}
self.prev_checkpoint_config = saved.parent_config
self.checkpoint_id_saved = saved.checkpoint["id"]
self.checkpoint = saved.checkpoint
self.checkpoint_metadata = saved.metadata
self.checkpoint_pending_writes = (
+19 -2
View File
@@ -7,6 +7,7 @@ from typing import (
List,
Optional,
Sequence,
TypeVar,
Union,
cast,
)
@@ -15,11 +16,16 @@ from uuid import UUID, uuid4
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatGenerationChunk, LLMResult
from langchain_core.tracers._streaming import T, _StreamingCallbackHandler
from langgraph.constants import NS_SEP, TAG_HIDDEN, TAG_NOSTREAM
from langgraph.types import StreamChunk
from langgraph.types import Command, StreamChunk
try:
from langchain_core.tracers._streaming import _StreamingCallbackHandler
except ImportError:
_StreamingCallbackHandler = object # type: ignore
T = TypeVar("T")
Meta = tuple[tuple[str, ...], dict[str, Any]]
@@ -147,6 +153,17 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
**kwargs: Any,
) -> Any:
if meta := self.metadata.pop(run_id, None):
if isinstance(response, Command):
response = response.update
if isinstance(response, Sequence) and any(
isinstance(value, Command) for value in response
):
response = [
value.update if isinstance(value, Command) else value
for value in response
]
if isinstance(response, BaseMessage):
self._emit(meta, response, dedupe=True)
elif isinstance(response, Sequence):
+20 -4
View File
@@ -30,6 +30,7 @@ from langgraph.utils.config import merge_configs
from langgraph.utils.runnable import RunnableCallable, RunnableSeq
READ_TYPE = Callable[[Union[str, Sequence[str]], bool], Union[Any, dict[str, Any]]]
INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
class ChannelRead(RunnableCallable):
@@ -67,6 +68,7 @@ class ChannelRead(RunnableCallable):
afunc=self._aread,
tags=tags,
name=None,
trace=False,
func_accepts_config=True,
)
self.fresh = fresh
@@ -144,8 +146,8 @@ class PregelNode(Runnable):
"""The main logic of the node. This will be invoked with the input from
`channels`."""
retry_policy: Optional[RetryPolicy]
"""The retry policy to use when invoking the node."""
retry_policy: Optional[Sequence[RetryPolicy]]
"""The retry policies to use when invoking the node."""
tags: Optional[Sequence[str]]
"""Tags to attach to the node for tracing."""
@@ -166,7 +168,7 @@ class PregelNode(Runnable):
tags: Optional[list[str]] = None,
metadata: Optional[Mapping[str, Any]] = None,
bound: Optional[Runnable[Any, Any]] = None,
retry_policy: Optional[RetryPolicy] = None,
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
subgraphs: Optional[Sequence[PregelProtocol]] = None,
) -> None:
self.channels = channels
@@ -174,7 +176,10 @@ class PregelNode(Runnable):
self.mapper = mapper
self.writers = writers or []
self.bound = bound if bound is not None else DEFAULT_BOUND
self.retry_policy = retry_policy
if isinstance(retry_policy, RetryPolicy):
self.retry_policy: Sequence[RetryPolicy] = (retry_policy,)
else:
self.retry_policy = retry_policy
self.tags = tags
self.metadata = metadata
if subgraphs is not None:
@@ -228,6 +233,17 @@ class PregelNode(Runnable):
else:
return self.bound
@cached_property
def input_cache_key(self) -> INPUT_CACHE_KEY_TYPE:
"""Get a cache key for the input to the node.
This is used to avoid calculating the same input multiple times."""
return (
self.mapper,
tuple(f"{key}:{value}" for key, value in self.channels.items())
if isinstance(self.channels, dict)
else tuple(self.channels),
)
def join(self, channels: Sequence[str]) -> PregelNode:
assert isinstance(channels, list) or isinstance(
channels, tuple
+36 -46
View File
@@ -10,7 +10,6 @@ from typing import (
cast,
)
import orjson
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.graph import (
Edge as DrawableEdge,
@@ -35,6 +34,8 @@ from typing_extensions import Self
from langgraph.checkpoint.base import CheckpointMetadata
from langgraph.constants import (
CONF,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_STREAM,
INTERRUPT,
@@ -46,6 +47,14 @@ from langgraph.pregel.types import All, PregelTask, StateSnapshot, StreamMode
from langgraph.types import Command, Interrupt, StreamProtocol
from langgraph.utils.config import merge_configs
CONF_DROPLIST = frozenset(
(
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
),
)
class RemoteException(Exception):
"""Exception raised when an error occurs in the remote graph."""
@@ -290,47 +299,26 @@ class RemoteGraph(PregelProtocol):
}
def _sanitize_config(self, config: RunnableConfig) -> RunnableConfig:
reserved_configurable_keys = frozenset(
[
"callbacks",
"checkpoint_map",
"checkpoint_id",
"checkpoint_ns",
]
)
def _sanitize_obj(obj: Any) -> Any:
"""Remove non-JSON serializable fields from the given object."""
if isinstance(obj, dict):
return {k: _sanitize_obj(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_sanitize_obj(v) for v in obj]
else:
try:
orjson.dumps(obj)
return obj
except orjson.JSONEncodeError:
return None
# Remove non-JSON serializable fields from the config.
config = _sanitize_obj(config)
# Only include configurable keys that are not reserved and
# not starting with "__pregel_" prefix.
new_configurable = {
k: v
for k, v in config["configurable"].items()
if k not in reserved_configurable_keys and not k.startswith("__pregel_")
}
sanitized: RunnableConfig = {
"tags": config.get("tags") or [],
"metadata": config.get("metadata") or {},
"configurable": new_configurable,
}
"""Sanitize the config to remove non-serializable fields."""
sanitized: RunnableConfig = {}
if "recursion_limit" in config:
sanitized["recursion_limit"] = config["recursion_limit"]
if "tags" in config:
sanitized["tags"] = [tag for tag in config["tags"] if isinstance(tag, str)]
if "metadata" in config:
sanitized["metadata"] = {}
for k, v in config["metadata"].items():
if isinstance(k, str) and isinstance(v, (str, int, float, bool)):
sanitized["metadata"][k] = v
if "configurable" in config:
sanitized["configurable"] = {}
for k, v in config["configurable"].items():
if (
isinstance(k, str)
and k not in CONF_DROPLIST
and isinstance(v, (str, int, float, bool))
):
sanitized["configurable"][k] = v
return sanitized
def get_state(
@@ -654,9 +642,10 @@ class RemoteGraph(PregelProtocol):
# raise interrupt or errors
if chunk.event.startswith("updates"):
if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
raise GraphInterrupt(
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
)
if caller_ns:
raise GraphInterrupt(
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
)
elif chunk.event.startswith("error"):
raise RemoteException(chunk.data)
# filter for what was actually requested
@@ -748,9 +737,10 @@ class RemoteGraph(PregelProtocol):
# raise interrupt or errors
if chunk.event.startswith("updates"):
if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
raise GraphInterrupt(
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
)
if caller_ns:
raise GraphInterrupt(
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
)
elif chunk.event.startswith("error"):
raise RemoteException(chunk.data)
# filter for what was actually requested
+66 -50
View File
@@ -22,12 +22,11 @@ SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
def run_with_retry(
task: PregelExecutableTask,
retry_policy: Optional[RetryPolicy],
retry_policy: Optional[Sequence[RetryPolicy]],
configurable: Optional[dict[str, Any]] = None,
) -> None:
"""Run a task with retries."""
retry_policy = task.retry_policy or retry_policy
interval = retry_policy.initial_interval if retry_policy else 0
attempts = 0
config = task.config
if configurable is not None:
@@ -63,38 +62,39 @@ def run_with_retry(
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
if retry_policy is None:
raise
# Check which retry policy applies to this exception
matching_policy = None
for policy in retry_policy:
if _should_retry_on(policy, exc):
matching_policy = policy
break
if not matching_policy:
raise
# increment attempts
attempts += 1
# check if we should retry
if isinstance(retry_policy.retry_on, Sequence):
if not isinstance(exc, tuple(retry_policy.retry_on)):
raise
elif isinstance(retry_policy.retry_on, type) and issubclass(
retry_policy.retry_on, Exception
):
if not isinstance(exc, retry_policy.retry_on):
raise
elif callable(retry_policy.retry_on):
if not retry_policy.retry_on(exc): # type: ignore[call-arg]
raise
else:
raise TypeError(
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
)
# check if we should give up
if attempts >= retry_policy.max_attempts:
if attempts >= matching_policy.max_attempts:
raise
# sleep before retrying
interval = matching_policy.initial_interval
# Apply backoff factor based on attempt count
interval = min(
retry_policy.max_interval,
interval * retry_policy.backoff_factor,
matching_policy.max_interval,
interval * (matching_policy.backoff_factor ** (attempts - 1)),
)
time.sleep(
interval + random.uniform(0, 1) if retry_policy.jitter else interval
# Apply jitter if configured
sleep_time = (
interval + random.uniform(0, 1) if matching_policy.jitter else interval
)
time.sleep(sleep_time)
# log the retry
logger.info(
f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
exc_info=exc,
)
# signal subgraphs to resume (if available)
@@ -103,13 +103,12 @@ def run_with_retry(
async def arun_with_retry(
task: PregelExecutableTask,
retry_policy: Optional[RetryPolicy],
retry_policies: Optional[Sequence[RetryPolicy]],
stream: bool = False,
configurable: Optional[dict[str, Any]] = None,
) -> None:
"""Run a task asynchronously with retries."""
retry_policy = task.retry_policy or retry_policy
interval = retry_policy.initial_interval if retry_policy else 0
retry_policies = task.retry_policy or retry_policies
attempts = 0
config = task.config
if configurable is not None:
@@ -149,41 +148,58 @@ async def arun_with_retry(
except Exception as exc:
if SUPPORTS_EXC_NOTES:
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
if retry_policy is None:
if retry_policies is None:
raise
# Check which retry policy applies to this exception
matching_policy = None
for policy in retry_policies:
if _should_retry_on(policy, exc):
matching_policy = policy
break
if not matching_policy:
raise
# increment attempts
attempts += 1
# check if we should retry
if isinstance(retry_policy.retry_on, Sequence):
if not isinstance(exc, tuple(retry_policy.retry_on)):
raise
elif isinstance(retry_policy.retry_on, type) and issubclass(
retry_policy.retry_on, Exception
):
if not isinstance(exc, retry_policy.retry_on):
raise
elif callable(retry_policy.retry_on):
if not retry_policy.retry_on(exc): # type: ignore[call-arg]
raise
else:
raise TypeError(
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
)
# check if we should give up
if attempts >= retry_policy.max_attempts:
if attempts >= matching_policy.max_attempts:
raise
# sleep before retrying
interval = matching_policy.initial_interval
# Apply backoff factor based on attempt count
interval = min(
retry_policy.max_interval,
interval * retry_policy.backoff_factor,
matching_policy.max_interval,
interval * (matching_policy.backoff_factor ** (attempts - 1)),
)
await asyncio.sleep(
interval + random.uniform(0, 1) if retry_policy.jitter else interval
# Apply jitter if configured
sleep_time = (
interval + random.uniform(0, 1) if matching_policy.jitter else interval
)
await asyncio.sleep(sleep_time)
# log the retry
logger.info(
f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
exc_info=exc,
)
# signal subgraphs to resume (if available)
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
def _should_retry_on(retry_policy: RetryPolicy, exc: Exception) -> bool:
"""Check if the given exception should be retried based on the retry policy."""
if isinstance(retry_policy.retry_on, Sequence):
return isinstance(exc, tuple(retry_policy.retry_on))
elif isinstance(retry_policy.retry_on, type) and issubclass(
retry_policy.retry_on, Exception
):
return isinstance(exc, retry_policy.retry_on)
elif callable(retry_policy.retry_on):
return retry_policy.retry_on(exc) # type: ignore[call-arg]
else:
raise TypeError(
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
)
+4 -4
View File
@@ -140,7 +140,7 @@ class PregelRunner:
*,
reraise: bool = True,
timeout: Optional[float] = None,
retry_policy: Optional[RetryPolicy] = None,
retry_policy: Optional[Sequence[RetryPolicy]] = None,
get_waiter: Optional[Callable[[], concurrent.futures.Future[None]]] = None,
) -> Iterator[None]:
tasks = tuple(tasks)
@@ -269,7 +269,7 @@ class PregelRunner:
*,
reraise: bool = True,
timeout: Optional[float] = None,
retry_policy: Optional[RetryPolicy] = None,
retry_policy: Optional[Sequence[RetryPolicy]] = None,
get_waiter: Optional[Callable[[], asyncio.Future[None]]] = None,
) -> AsyncIterator[None]:
loop = asyncio.get_event_loop()
@@ -519,7 +519,7 @@ def _call(
func: Callable[[Any], Union[Awaitable[Any], Any]],
input: Any,
*,
retry: Optional[RetryPolicy] = None,
retry: Optional[Sequence[RetryPolicy]] = None,
callbacks: Callbacks = None,
futures: weakref.ref[FuturesDict],
schedule_task: weakref.ref[
@@ -600,7 +600,7 @@ def _acall(
func: Callable[[Any], Union[Awaitable[Any], Any]],
input: Any,
*,
retry: Optional[RetryPolicy] = None,
retry: Optional[Sequence[RetryPolicy]] = None,
callbacks: Callbacks = None,
# injected dependencies
futures: weakref.ref[FuturesDict],
+4 -2
View File
@@ -54,14 +54,14 @@ class ChannelWrite(RunnableCallable):
self,
writes: Sequence[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]],
*,
tags: Optional[Sequence[str]] = None,
tags: Optional[Sequence[str]] = None, # ignored
require_at_least_one_of: Optional[Sequence[str]] = None, # ignored
):
super().__init__(
func=self._write,
afunc=self._awrite,
name=None,
tags=tags,
trace=False,
func_accepts_config=True,
)
self.writes = cast(
@@ -152,6 +152,8 @@ class ChannelWrite(RunnableCallable):
tuples.append((w.channel, value))
else:
raise ValueError(f"Invalid write entry: {w}")
# if we want to persist writes found before hitting a ParentCommand
# can move this to a finally block
write: TYPE_SEND = config[CONF][CONFIG_KEY_SEND]
write(tuples)
+7 -6
View File
@@ -23,6 +23,7 @@ from langchain_core.runnables import Runnable, RunnableConfig
from typing_extensions import Self
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
from langgraph.utils.fields import get_update_as_tuples
if TYPE_CHECKING:
from langgraph.pregel.protocol import PregelProtocol
@@ -75,6 +76,10 @@ def default_retry_on(exc: Exception) -> bool:
if isinstance(exc, ConnectionError):
return True
if isinstance(exc, httpx.HTTPStatusError):
return 500 <= exc.response.status_code < 600
if isinstance(exc, requests.HTTPError):
return 500 <= exc.response.status_code < 600 if exc.response else True
if isinstance(
exc,
(
@@ -93,10 +98,6 @@ def default_retry_on(exc: Exception) -> bool:
),
):
return False
if isinstance(exc, httpx.HTTPStatusError):
return 500 <= exc.response.status_code < 600
if isinstance(exc, requests.HTTPError):
return 500 <= exc.response.status_code < 600 if exc.response else True
return True
@@ -172,7 +173,7 @@ class PregelExecutableTask:
writes: deque[tuple[str, Any]]
config: RunnableConfig
triggers: Sequence[str]
retry_policy: Optional[RetryPolicy]
retry_policy: Optional[Sequence[RetryPolicy]]
cache_policy: Optional[CachePolicy]
id: str
path: tuple[Union[str, int, tuple], ...]
@@ -318,7 +319,7 @@ class Command(Generic[N], ToolOutputMixin):
):
return self.update
elif hints := get_type_hints(type(self.update)):
return [(k, getattr(self.update, k)) for k in hints]
return get_update_as_tuples(self.update, tuple(hints.keys()))
elif self.update is not None:
return [("__root__", self.update)]
else:
+37 -1
View File
@@ -1,8 +1,14 @@
import dataclasses
from typing import Any, Generator, Optional, Type, Union, get_type_hints
from typing import Any, Generator, Optional, Sequence, Type, Union, get_type_hints
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated, NotRequired, ReadOnly, Required, get_origin
# NOTE: this is redefined here separately from langgraph.constants
# to avoid a circular import
MISSING = object()
def _is_optional_type(type_: Any) -> bool:
"""Check if a type is Optional."""
@@ -147,3 +153,33 @@ def get_enhanced_type_hints(
pass
yield name, typ, default, description
def get_update_as_tuples(input: Any, keys: Sequence[str]) -> list[tuple[str, Any]]:
"""Get Pydantic state update as a list of (key, value) tuples."""
# Pydantic v1
if isinstance(input, BaseModelV1):
keep: Optional[set[str]] = input.__fields_set__
defaults = {k: v.default for k, v in input.__fields__.items()}
# Pydantic v2
elif isinstance(input, BaseModel):
keep = input.model_fields_set
defaults = {k: v.default for k, v in input.model_fields.items()}
else:
keep = None
defaults = {}
# NOTE: This behavior for Pydantic is somewhat inelegant,
# but we keep around for backwards compatibility
# if input is a Pydantic model, only update values
# that are different from the default values or in the keep set
return [
(k, value)
for k in keys
if (value := getattr(input, k, MISSING)) is not MISSING
and (
value is not None
or defaults.get(k, MISSING) is not None
or (keep is not None and k in keep)
)
]
+247 -148
View File
@@ -36,7 +36,7 @@ from langchain_core.runnables.config import (
var_child_runnable_config,
)
from langchain_core.runnables.utils import Input, Output
from langchain_core.tracers._streaming import _StreamingCallbackHandler
from langchain_core.tracers.langchain import LangChainTracer
from typing_extensions import TypeGuard
from langgraph.constants import (
@@ -54,60 +54,41 @@ from langgraph.utils.config import (
patch_config,
)
try:
from langchain_core.tracers._streaming import _StreamingCallbackHandler
except ImportError:
_StreamingCallbackHandler = None # type: ignore
def _set_config_context(
config: RunnableConfig,
) -> tuple[Token[Optional[RunnableConfig]], Optional[dict[str, Any]]]:
config: RunnableConfig, run: Any = None
) -> Token[Optional[RunnableConfig]]:
"""Set the child Runnable config + tracing context.
Args:
config (RunnableConfig): The config to set.
"""
from langchain_core.tracers.langchain import LangChainTracer
config_token = var_child_runnable_config.set(config)
current_context = None
if (
(callbacks := config.get("callbacks"))
and (
parent_run_id := getattr(callbacks, "parent_run_id", None)
) # Is callback manager
and (
tracer := next(
(
handler
for handler in getattr(callbacks, "handlers", [])
if isinstance(handler, LangChainTracer)
),
None,
)
)
and (run := tracer.run_map.get(str(parent_run_id)))
):
from langsmith.run_helpers import _set_tracing_context, get_tracing_context
if run is not None:
from langsmith.run_helpers import _set_tracing_context
current_context = get_tracing_context()
_set_tracing_context({"parent": run})
return config_token, current_context
return config_token
@contextmanager
def set_config_context(config: RunnableConfig) -> Generator[Context, None, None]:
def _unset_config_context(
token: Token[Optional[RunnableConfig]], run: Any = None
) -> None:
"""Set the child Runnable config + tracing context.
Args:
config (RunnableConfig): The config to set.
"""
from langsmith.run_helpers import _set_tracing_context
var_child_runnable_config.reset(token)
if run is not None:
from langsmith.run_helpers import _set_tracing_context
ctx = copy_context()
config_token, _ = ctx.run(_set_config_context, config)
try:
yield ctx
finally:
ctx.run(var_child_runnable_config.reset, config_token)
ctx.run(
_set_tracing_context,
_set_tracing_context(
{
"parent": None,
"project_name": None,
@@ -115,10 +96,27 @@ def set_config_context(config: RunnableConfig) -> Generator[Context, None, None]
"metadata": None,
"enabled": None,
"client": None,
},
}
)
@contextmanager
def set_config_context(
config: RunnableConfig, run: Any = None
) -> Generator[Context, None, None]:
"""Set the child Runnable config + tracing context.
Args:
config (RunnableConfig): The config to set.
"""
ctx = copy_context()
config_token = ctx.run(_set_config_context, config, run)
try:
yield ctx
finally:
ctx.run(_unset_config_context, config_token, run)
# Before Python 3.11 native StrEnum is not available
class StrEnum(str, enum.Enum):
"""A string enum."""
@@ -359,7 +357,15 @@ class RunnableCallable(Runnable):
)
try:
child_config = patch_config(config, callbacks=run_manager.get_child())
with set_config_context(child_config) as context:
# get the run
for h in run_manager.handlers:
if isinstance(h, LangChainTracer):
run = h.run_map.get(str(run_manager.run_id))
break
else:
run = None
# run in context
with set_config_context(child_config, run) as context:
ret = context.run(self.func, *args, **kwargs)
except BaseException as e:
run_manager.on_chain_error(e)
@@ -367,9 +373,8 @@ class RunnableCallable(Runnable):
else:
run_manager.on_chain_end(ret)
else:
with set_config_context(config) as context:
ret = context.run(self.func, *args, **kwargs)
if isinstance(ret, Runnable) and self.recurse:
ret = self.func(*args, **kwargs)
if self.recurse and isinstance(ret, Runnable):
return ret.invoke(input, config)
return ret
@@ -413,25 +418,26 @@ class RunnableCallable(Runnable):
)
try:
child_config = patch_config(config, callbacks=run_manager.get_child())
with set_config_context(child_config) as context:
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
if ASYNCIO_ACCEPTS_CONTEXT:
ret = await asyncio.create_task(coro, context=context)
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
if ASYNCIO_ACCEPTS_CONTEXT:
for h in run_manager.handlers:
if isinstance(h, LangChainTracer):
run = h.run_map.get(str(run_manager.run_id))
break
else:
ret = await coro
run = None
with set_config_context(child_config, run) as context:
ret = await asyncio.create_task(coro, context=context)
else:
ret = await coro
except BaseException as e:
await run_manager.on_chain_error(e)
raise
else:
await run_manager.on_chain_end(ret)
else:
with set_config_context(config) as context:
if ASYNCIO_ACCEPTS_CONTEXT:
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
ret = await asyncio.create_task(coro, context=context)
else:
ret = await self.afunc(*args, **kwargs)
if isinstance(ret, Runnable) and self.recurse:
ret = await self.afunc(*args, **kwargs)
if self.recurse and isinstance(ret, Runnable):
return await ret.ainvoke(input, config)
return ret
@@ -594,7 +600,6 @@ class RunnableSeq(Runnable):
name=config.get("run_name") or self.get_name(),
run_id=config.pop("run_id", None),
)
# invoke all steps in sequence
try:
for i, step in enumerate(self.steps):
@@ -602,8 +607,19 @@ class RunnableSeq(Runnable):
config = patch_config(
config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
)
# 1st step is the actual node,
# others are writers which don't need to be run in context
if i == 0:
input = step.invoke(input, config, **kwargs)
# get the run object
for h in run_manager.handlers:
if isinstance(h, LangChainTracer):
run = h.run_map.get(str(run_manager.run_id))
break
else:
run = None
# run in context
with set_config_context(config, run) as context:
input = context.run(step.invoke, input, config, **kwargs)
else:
input = step.invoke(input, config)
# finish the root run
@@ -639,8 +655,24 @@ class RunnableSeq(Runnable):
config = patch_config(
config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
)
# 1st step is the actual node,
# others are writers which don't need to be run in context
if i == 0:
input = await step.ainvoke(input, config, **kwargs)
if ASYNCIO_ACCEPTS_CONTEXT:
# get the run object
for h in run_manager.handlers:
if isinstance(h, LangChainTracer):
run = h.run_map.get(str(run_manager.run_id))
break
else:
run = None
# run in context
with set_config_context(config, run) as context:
input = await asyncio.create_task(
step.ainvoke(input, config, **kwargs), context=context
)
else:
input = await step.ainvoke(input, config, **kwargs)
else:
input = await step.ainvoke(input, config)
# finish the root run
@@ -668,51 +700,48 @@ class RunnableSeq(Runnable):
name=config.get("run_name") or self.get_name(),
run_id=config.pop("run_id", None),
)
try:
# 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
# buffer input in memory until all available, and then start emitting output
for idx, step in enumerate(self.steps):
config = patch_config(
config,
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
)
if idx == 0:
iterator = step.stream(input, config, **kwargs)
else:
iterator = step.transform(iterator, config)
if stream_handler := next(
(
cast(_StreamingCallbackHandler, h)
for h in run_manager.handlers
if isinstance(h, _StreamingCallbackHandler)
),
None,
):
# populates streamed_output in astream_log() output if needed
iterator = stream_handler.tap_output_iter(run_manager.run_id, iterator)
output: Any = None
add_supported = False
for chunk in iterator:
yield chunk
# collect final output
if output is None:
output = chunk
elif add_supported:
try:
output = output + chunk
except TypeError:
output = chunk
add_supported = False
else:
output = chunk
except BaseException as e:
run_manager.on_chain_error(e)
raise
# get the run object
for h in run_manager.handlers:
if isinstance(h, LangChainTracer):
run = h.run_map.get(str(run_manager.run_id))
break
else:
run_manager.on_chain_end(output)
run = None
# create first step config
config = patch_config(
config,
callbacks=run_manager.get_child(f"seq:step:{1}"),
)
# run all in context
with set_config_context(config, run) as context:
try:
# 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
# buffer input in memory until all available, and then start emitting output
for idx, step in enumerate(self.steps):
if idx == 0:
iterator = step.stream(input, config, **kwargs)
else:
config = patch_config(
config,
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
)
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)
# consume into final output
output = context.run(_consume_iter, iterator)
# sequence doesn't emit output, yield to mark as generator
yield
except BaseException as e:
run_manager.on_chain_error(e)
raise
else:
run_manager.on_chain_end(output)
async def astream(
self,
@@ -731,51 +760,121 @@ class RunnableSeq(Runnable):
name=config.get("run_name") or self.get_name(),
run_id=config.pop("run_id", None),
)
try:
async with AsyncExitStack() as stack:
# 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
# buffer input in memory until all available, and then start emitting output
for idx, step in enumerate(self.steps):
config = patch_config(
config,
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
)
if idx == 0:
aiterator = step.astream(input, config, **kwargs)
else:
aiterator = step.atransform(aiterator, config)
if hasattr(aiterator, "aclose"):
stack.push_async_callback(aiterator.aclose)
if stream_handler := next(
(
cast(_StreamingCallbackHandler, h)
for h in run_manager.handlers
if isinstance(h, _StreamingCallbackHandler)
),
None,
):
# populates streamed_output in astream_log() output if needed
aiterator = stream_handler.tap_output_aiter(
run_manager.run_id, aiterator
)
output: Any = None
add_supported = False
async for chunk in aiterator:
yield chunk
# collect final output
if add_supported:
try:
output = output + chunk
except TypeError:
output = chunk
add_supported = False
else:
output = chunk
except BaseException as e:
await run_manager.on_chain_error(e)
raise
# 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
# buffer input in memory until all available, and then start emitting output
if ASYNCIO_ACCEPTS_CONTEXT:
# get the run object
for h in run_manager.handlers:
if isinstance(h, LangChainTracer):
run = h.run_map.get(str(run_manager.run_id))
break
else:
run = None
# create first step config
config = patch_config(
config,
callbacks=run_manager.get_child(f"seq:step:{1}"),
)
# run all in context
with set_config_context(config, run) as context:
try:
async with AsyncExitStack() as stack:
for idx, step in enumerate(self.steps):
if idx == 0:
aiterator = step.astream(input, config, **kwargs)
else:
config = patch_config(
config,
callbacks=run_manager.get_child(
f"seq:step:{idx + 1}"
),
)
aiterator = step.atransform(aiterator, config)
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
)
# consume into final output
output = await asyncio.create_task(
_consume_aiter(aiterator), context=context
)
# sequence doesn't emit output, yield to mark as generator
yield
except BaseException as e:
await run_manager.on_chain_error(e)
raise
else:
await run_manager.on_chain_end(output)
else:
await run_manager.on_chain_end(output)
try:
async with AsyncExitStack() as stack:
for idx, step in enumerate(self.steps):
config = patch_config(
config,
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
)
if idx == 0:
aiterator = step.astream(input, config, **kwargs)
else:
aiterator = step.atransform(aiterator, config)
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
)
# consume into final output
output = await _consume_aiter(aiterator)
# sequence doesn't emit output, yield to mark as generator
yield
except BaseException as e:
await run_manager.on_chain_error(e)
raise
else:
await run_manager.on_chain_end(output)
def _consume_iter(it: Iterator[Any]) -> Any:
"""Consume an iterator."""
output: Any = None
add_supported = False
for chunk in it:
# collect final output
if output is None:
output = chunk
elif add_supported:
try:
output = output + chunk
except TypeError:
output = chunk
add_supported = False
else:
output = chunk
return output
async def _consume_aiter(it: AsyncIterator[Any]) -> Any:
"""Consume an async iterator."""
output: Any = None
add_supported = False
async for chunk in it:
# collect final output
if add_supported:
try:
output = output + chunk
except TypeError:
output = chunk
add_supported = False
else:
output = chunk
return output
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.3.25"
version = "0.3.30"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
license = "MIT"
+27 -14
View File
@@ -1,20 +1,20 @@
import pytest
from pytest_mock import MockerFixture
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
from tests.conftest import (
ALL_CHECKPOINTERS_ASYNC,
ALL_CHECKPOINTERS_SYNC,
REGULAR_CHECKPOINTERS_ASYNC,
REGULAR_CHECKPOINTERS_SYNC,
awith_checkpointer,
)
pytestmark = pytest.mark.anyio
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
def test_interruption_without_state_updates(
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
"""Test interruption without state updates. This test confirms that
interrupting doesn't require a state key having been updated in the prev step"""
@@ -40,20 +40,27 @@ def test_interruption_without_state_updates(
initial_input = {"input": "hello world"}
thread = {"configurable": {"thread_id": "1"}}
graph.invoke(initial_input, thread, debug=True)
graph.invoke(initial_input, thread, checkpoint_during=checkpoint_during)
assert graph.get_state(thread).next == ("step_2",)
n_checkpoints = len([c for c in graph.get_state_history(thread)])
assert n_checkpoints == (3 if checkpoint_during else 1)
graph.invoke(None, thread, debug=True)
graph.invoke(None, thread, checkpoint_during=checkpoint_during)
assert graph.get_state(thread).next == ("step_3",)
n_checkpoints = len([c for c in graph.get_state_history(thread)])
assert n_checkpoints == (4 if checkpoint_during else 2)
graph.invoke(None, thread, debug=True)
graph.invoke(None, thread, checkpoint_during=checkpoint_during)
assert graph.get_state(thread).next == ()
n_checkpoints = len([c for c in graph.get_state_history(thread)])
assert n_checkpoints == (5 if checkpoint_during else 3)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_interruption_without_state_updates_async(
checkpointer_name: str, mocker: MockerFixture
):
checkpointer_name: str, checkpoint_during: bool
) -> None:
"""Test interruption without state updates. This test confirms that
interrupting doesn't require a state key having been updated in the prev step"""
@@ -78,11 +85,17 @@ async def test_interruption_without_state_updates_async(
initial_input = {"input": "hello world"}
thread = {"configurable": {"thread_id": "1"}}
await graph.ainvoke(initial_input, thread, debug=True)
await graph.ainvoke(initial_input, thread, checkpoint_during=checkpoint_during)
assert (await graph.aget_state(thread)).next == ("step_2",)
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (3 if checkpoint_during else 1)
await graph.ainvoke(None, thread, debug=True)
await graph.ainvoke(None, thread, checkpoint_during=checkpoint_during)
assert (await graph.aget_state(thread)).next == ("step_3",)
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (4 if checkpoint_during else 2)
await graph.ainvoke(None, thread, debug=True)
await graph.ainvoke(None, thread, checkpoint_during=checkpoint_during)
assert (await graph.aget_state(thread)).next == ()
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (5 if checkpoint_during else 3)
+19 -21
View File
@@ -4660,7 +4660,7 @@ def test_root_graph(
content="result for query",
name="search_api",
tool_call_id="tool_call123",
id="00000000-0000-4000-8000-000000000040",
id="00000000-0000-4000-8000-000000000024",
)
]
},
@@ -4683,7 +4683,7 @@ def test_root_graph(
content="result for another",
name="search_api",
tool_call_id="tool_call456",
id="00000000-0000-4000-8000-000000000049",
id="00000000-0000-4000-8000-000000000030",
)
]
},
@@ -5387,7 +5387,7 @@ def test_root_graph(
"__root__": [
HumanMessage(
content="what is weather in sf",
id="00000000-0000-4000-8000-000000000083",
id="00000000-0000-4000-8000-000000000051",
),
AIMessage(
content="",
@@ -5407,7 +5407,7 @@ def test_root_graph(
),
AIMessage(content="answer", id="ai2"),
AIMessage(
content="an extra message", id="00000000-0000-4000-8000-000000000107"
content="an extra message", id="00000000-0000-4000-8000-000000000066"
),
HumanMessage(content="what is weather in la"),
],
@@ -7258,9 +7258,10 @@ def test_branch_then(
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
def test_send_dedupe_on_resume(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@@ -7316,7 +7317,7 @@ def test_send_dedupe_on_resume(
graph = builder.compile(checkpointer=checkpointer)
thread1 = {"configurable": {"thread_id": "1"}}
assert graph.invoke(["0"], thread1, debug=1) == [
assert graph.invoke(["0"], thread1, checkpoint_during=checkpoint_during) == [
"0",
"1",
"3.1",
@@ -7333,12 +7334,11 @@ def test_send_dedupe_on_resume(
pytest.xfail("TODO: shallow checkpointer reports wrong next set")
assert state.next == ("flaky",)
# check history
if "shallow" not in checkpointer_name:
history = [c for c in graph.get_state_history(thread1)]
assert len(history) == 4
history = [c for c in graph.get_state_history(thread1)]
assert len(history) == (4 if checkpoint_during else 1)
# resume execution
assert graph.invoke(None, thread1, debug=1) == [
assert graph.invoke(None, thread1, checkpoint_during=checkpoint_during) == [
"0",
"1",
"3.1",
@@ -7358,6 +7358,7 @@ def test_send_dedupe_on_resume(
assert state.next == ()
# check history
history = [c for c in graph.get_state_history(thread1)]
assert len(history) == (6 if checkpoint_during else 2)
expected_history = [
StateSnapshot(
values=[
@@ -7494,13 +7495,9 @@ def test_send_dedupe_on_resume(
name="flaky",
path=("__pregel_push", 1),
error=None,
interrupts=(
Interrupt(
value="Bahh", resumable=False, ns=None, when="during"
),
),
interrupts=(Interrupt(value="Bahh", resumable=False, ns=None),),
state=None,
result=["flaky|4"],
result=["flaky|4"] if checkpoint_during else None,
),
PregelTask(
id=AnyStr(),
@@ -7637,10 +7634,11 @@ def test_send_dedupe_on_resume(
),
),
]
if "shallow" in checkpointer_name:
expected_history = expected_history[:1]
assert history == expected_history
if checkpoint_during:
assert history == expected_history
else:
assert history[0] == expected_history[0]
assert history[1] == expected_history[2]
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
+458 -60
View File
@@ -1,9 +1,14 @@
import datetime
import decimal
import enum
import functools
import gc
import ipaddress
import json
import logging
import operator
import pathlib
import re
import threading
import time
import uuid
@@ -12,6 +17,7 @@ from collections import Counter, deque
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import Enum
from random import randrange
from typing import (
Annotated,
@@ -1115,10 +1121,14 @@ def test_invoke_checkpoint_two(
assert checkpoint["channel_values"].get("total") == 5
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_pending_writes_resume(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
f"checkpointer_{checkpointer_name}"
)
@@ -1144,17 +1154,19 @@ def test_pending_writes_resume(
self.calls = 0
one = AwhileMaker(0.1, {"value": 2})
two = AwhileMaker(0.3, ConnectionError("I'm not good"))
two = AwhileMaker(0.2, ConnectionError("I'm not good"))
builder = StateGraph(State)
builder.add_node("one", one)
builder.add_node("two", two, retry=RetryPolicy(max_attempts=2))
builder.add_node(
"two", two, retry=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False)
)
builder.add_edge(START, "one")
builder.add_edge(START, "two")
graph = builder.compile(checkpointer=checkpointer)
thread1: RunnableConfig = {"configurable": {"thread_id": "1"}}
with pytest.raises(ConnectionError, match="I'm not good"):
graph.invoke({"value": 1}, thread1)
graph.invoke({"value": 1}, thread1, checkpoint_during=checkpoint_during)
# both nodes should have been called once
assert one.calls == 1
@@ -1200,7 +1212,7 @@ def test_pending_writes_resume(
# resume execution
with pytest.raises(ConnectionError, match="I'm not good"):
graph.invoke(None, thread1)
graph.invoke(None, thread1, checkpoint_during=checkpoint_during)
# node "one" succeeded previously, so shouldn't be called again
assert one.calls == 1
@@ -1214,7 +1226,9 @@ def test_pending_writes_resume(
# resume execution, without exception
two.rtn = {"value": 3}
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
assert graph.invoke(None, thread1) == {"value": 6}
assert graph.invoke(None, thread1, checkpoint_during=checkpoint_during) == {
"value": 6
}
if "shallow" in checkpointer_name:
assert len(list(checkpointer.list(thread1))) == 1
@@ -1223,7 +1237,7 @@ def test_pending_writes_resume(
# check all final checkpoints
checkpoints = [c for c in checkpointer.list(thread1)]
# we should have 3
assert len(checkpoints) == 3
assert len(checkpoints) == (3 if checkpoint_during else 2)
# the last one not too interesting for this test
assert checkpoints[0] == CheckpointTuple(
config={
@@ -1325,15 +1339,26 @@ def test_pending_writes_resume(
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"],
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"]
if checkpoint_during
else AnyStr(),
}
},
pending_writes=UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
(AnyStr(), "value", 3),
)
if checkpoint_during
else UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
# the write against the previous checkpoint is not saved, as it is
# produced in a run where only the next checkpoint (the last) is saved
),
)
if not checkpoint_during:
return
assert checkpoints[2] == CheckpointTuple(
config={
"configurable": {
@@ -1491,8 +1516,14 @@ def test_send_sequences() -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
def test_imp_task(
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
mapper_calls = 0
@@ -1558,7 +1589,7 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [*graph.stream([0, 1], thread1)] == [
assert [*graph.stream([0, 1], thread1, checkpoint_during=checkpoint_during)] == [
{"mapper": "00"},
{"mapper": "11"},
{
@@ -1574,17 +1605,23 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
]
assert mapper_calls == 2
assert graph.invoke(Command(resume="answer"), thread1) == [
assert graph.invoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
"00answer",
"11answer",
]
assert mapper_calls == 2
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_nested(
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
def mynode(input: list[str]) -> list[str]:
@@ -1626,7 +1663,7 @@ def test_imp_nested(
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [*graph.stream([0, 1], thread1)] == [
assert [*graph.stream([0, 1], thread1, checkpoint_during=checkpoint_during)] == [
{"submapper": "0"},
{"mapper": "00"},
{"submapper": "1"},
@@ -1643,16 +1680,22 @@ def test_imp_nested(
},
]
assert graph.invoke(Command(resume="answer"), thread1) == [
assert graph.invoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
"00answera",
"11answera",
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_stream_order(
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@task()
@@ -1675,7 +1718,10 @@ def test_imp_stream_order(
return fut_baz.result()
thread1 = {"configurable": {"thread_id": "1"}}
assert [c for c in graph.stream({"a": "0"}, thread1)] == [
assert [
c
for c in graph.stream({"a": "0"}, thread1, checkpoint_during=checkpoint_during)
] == [
{
"foo": (
"0foo",
@@ -2735,6 +2781,9 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
checkpointer_name: str,
) -> None:
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
IS_V1 = BaseModel is BaseModelV1
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
setup = mocker.Mock()
@@ -2773,14 +2822,28 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
class InnerObject(BaseModel):
yo: int
class State(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
if IS_V1:
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
class State(BaseModel):
class Config:
arbitrary_types_allowed = True
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
else:
class State(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
query: str
inner: Annotated[InnerObject, lambda x, y: y]
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
client: Annotated[httpx.Client, Context(make_httpx_client)]
class StateUpdate(BaseModel):
query: Optional[str] = None
@@ -3039,15 +3102,49 @@ def test_nested_pydantic_models(version: str) -> None:
"""Test that nested Pydantic models are properly constructed from leaf nodes up."""
# Define nested Pydantic models
# Import necessary modules
if version == "v1":
from pydantic.v1 import BaseModel, Field
from pydantic.v1 import ( # type: ignore
BaseModel,
ByteSize,
Field,
SecretStr,
confloat,
conint,
conlist,
constr,
)
else:
from pydantic import BaseModel, Field
from pydantic import ( # type: ignore
BaseModel,
ByteSize,
Field,
SecretStr,
confloat,
conint,
conlist,
constr,
)
from pydantic.v1 import BaseModel as BaseModelV1
if BaseModel is BaseModelV1:
pytest.skip("Cannot test pydantic v2 using installed version < 2")
class NestedModel(BaseModel):
value: int
name: str
# For constrained types
PositiveInt = Annotated[int, Field(gt=0)]
NonNegativeFloat = Annotated[float, Field(ge=0)]
# Enum type
class UserRole(Enum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
# Forward reference model
class RecursiveModel(BaseModel):
value: str
@@ -3068,12 +3165,19 @@ def test_nested_pydantic_models(version: str) -> None:
name: str
friends: list[str] = Field(default_factory=list) # IDs of friends
if version == "v2":
conlist_type = conlist(item_type=int, min_length=2, max_length=5)
else:
conlist_type = conlist(item_type=int, min_items=2, max_items=5)
class State(BaseModel):
# Basic nested model tests
top_level: str
auuid: uuid.UUID
nested: NestedModel
optional_nested: Annotated[Optional[NestedModel], lambda x, y: y, "Foo"]
dict_nested: dict[str, NestedModel]
simple_str_list: list[str]
list_nested: Annotated[
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
]
@@ -3090,15 +3194,51 @@ def test_nested_pydantic_models(version: str) -> None:
# Cyclic reference test
people: dict[str, Person] # Map of ID -> Person
# Rich type adapters
ip_address: ipaddress.IPv4Address
ip_address_v6: ipaddress.IPv6Address
amount: decimal.Decimal
file_path: pathlib.Path
timestamp: datetime.datetime
date_only: datetime.date
time_only: datetime.time
duration: datetime.timedelta
immutable_set: frozenset[int]
binary_data: bytes
pattern: re.Pattern
secret: SecretStr
file_size: ByteSize
# Constrained types
positive_value: PositiveInt
non_negative: NonNegativeFloat
limited_string: constr(min_length=3, max_length=10)
bounded_int: conint(ge=10, le=100)
restricted_float: confloat(gt=0, lt=1)
required_list: conlist_type
# Enum & Literal
role: UserRole
status: Literal["active", "inactive", "pending"]
# Annotated & NewType
validated_age: Annotated[int, Field(gt=0, lt=120)]
# Generic containers with validators
decimal_list: List[decimal.Decimal]
id_tuple: tuple[uuid.UUID, uuid.UUID]
inputs = {
# Basic nested models
"top_level": "initial",
"auuid": str(uuid.uuid4()),
"nested": {"value": 42, "name": "test"},
"optional_nested": {"value": 10, "name": "optional"},
"dict_nested": {"a": {"value": 5, "name": "a"}},
"list_nested": [{"a": {"value": 6, "name": "b"}}],
"tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}],
"tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]],
"simple_str_list": ["siss", "boom", "bah"],
"complex_tuple": [
"complex",
{"nested": [9, {"value": 10, "name": "deep"}]},
@@ -3125,6 +3265,35 @@ def test_nested_pydantic_models(version: str) -> None:
"friends": ["1", "2"], # Charlie is friends with Alice and Bob
},
},
# Rich type adapters
"ip_address": "192.168.1.1",
"ip_address_v6": "2001:db8::1",
"amount": "123.45",
"file_path": "/tmp/test.txt",
"timestamp": "2025-04-07T10:58:04",
"date_only": "2025-04-07",
"time_only": "10:58:04",
"duration": 3600, # seconds
"immutable_set": [1, 2, 3, 4],
"binary_data": b"hello world",
"pattern": "^test$",
"secret": "password123",
"file_size": 1024,
# Constrained types
"positive_value": 42,
"non_negative": 0.0,
"limited_string": "test",
"bounded_int": 50,
"restricted_float": 0.5,
"required_list": [10, 20, 30],
# Enum & Literal
"role": "admin",
"status": "active",
# Annotated & NewType
"validated_age": 30,
# Generic containers with validators
"decimal_list": ["10.5", "20.75", "30.25"],
"id_tuple": [str(uuid.uuid4()), str(uuid.uuid4())],
}
update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}}
@@ -3132,7 +3301,42 @@ def test_nested_pydantic_models(version: str) -> None:
expected = State(**inputs)
def node_fn(state: State) -> dict:
# Basic assertions
assert isinstance(state.auuid, uuid.UUID)
assert state == expected
# Rich type assertions
assert isinstance(state.ip_address, ipaddress.IPv4Address)
assert isinstance(state.ip_address_v6, ipaddress.IPv6Address)
assert isinstance(state.amount, decimal.Decimal)
assert isinstance(state.file_path, pathlib.Path)
assert isinstance(state.timestamp, datetime.datetime)
assert isinstance(state.date_only, datetime.date)
assert isinstance(state.time_only, datetime.time)
assert isinstance(state.duration, datetime.timedelta)
assert isinstance(state.immutable_set, frozenset)
assert isinstance(state.binary_data, bytes)
assert isinstance(state.pattern, re.Pattern)
# Constrained types
assert state.positive_value > 0
assert state.non_negative >= 0
assert 3 <= len(state.limited_string) <= 10
assert 10 <= state.bounded_int <= 100
assert 0 < state.restricted_float < 1
assert 2 <= len(state.required_list) <= 5
# Enum & Literal
assert state.role == UserRole.ADMIN
assert state.status == "active"
# Annotated
assert 0 < state.validated_age < 120
# Generic containers
assert len(state.decimal_list) == 3
assert len(state.id_tuple) == 2
return update
builder = StateGraph(State)
@@ -3643,10 +3847,14 @@ def test_nested_graph(snapshot: SnapshotAssertion) -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_subgraph_checkpoint_true(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
class InnerState(TypedDict):
@@ -3678,7 +3886,12 @@ def test_subgraph_checkpoint_true(
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "2"}}
assert [c for c in app.stream({"my_key": ""}, config, subgraphs=True)] == [
assert [
c
for c in app.stream(
{"my_key": ""}, config, subgraphs=True, checkpoint_during=checkpoint_during
)
] == [
(("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}),
(("inner",), {"inner_2": {"my_key": " and there"}}),
((), {"inner": {"my_key": " got here and there"}}),
@@ -3703,10 +3916,14 @@ def test_subgraph_checkpoint_true(
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_subgraph_checkpoint_true_interrupt(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
# Define subgraph
@@ -3745,15 +3962,18 @@ def test_subgraph_checkpoint_true_interrupt(
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
assert graph.invoke({"foo": "foo"}, config) == {"foo": "hi! foo"}
assert graph.invoke(
{"foo": "foo"}, config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foo"}
assert graph.get_state(config, subgraphs=True).tasks[0].state.values == {
"bar": "hi! foo"
}
assert graph.invoke(Command(resume="baz"), config) == {"foo": "hi! foobaz"}
assert graph.invoke(
Command(resume="baz"), config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foobaz"}
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
@@ -3869,10 +4089,14 @@ def test_stream_buffering_single_node(
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_nested_graph_interrupts_parallel(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
class InnerState(TypedDict):
@@ -3919,11 +4143,11 @@ def test_nested_graph_interrupts_parallel(
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert app.invoke({"my_key": ""}, config, debug=True) == {
assert app.invoke({"my_key": ""}, config, checkpoint_during=checkpoint_during) == {
"my_key": " and parallel",
}
assert app.invoke(None, config, debug=True) == {
assert app.invoke(None, config, checkpoint_during=checkpoint_during) == {
"my_key": "got here and there and parallel and back again",
}
@@ -3932,13 +4156,17 @@ def test_nested_graph_interrupts_parallel(
# - the writes of outer are persisted in 1st call and used in 2nd call, ie outer isn't called again (because we dont see outer_1 output again in 2nd stream)
# test stream updates w/ nested interrupt
config = {"configurable": {"thread_id": "2"}}
assert [*app.stream({"my_key": ""}, config, subgraphs=True)] == [
assert [
*app.stream(
{"my_key": ""}, config, subgraphs=True, checkpoint_during=checkpoint_during
)
] == [
# we got to parallel node first
((), {"outer_1": {"my_key": " and parallel"}}),
((AnyStr("inner:"),), {"inner_1": {"my_key": "got here", "my_other_key": ""}}),
((), {"__interrupt__": ()}),
]
assert [*app.stream(None, config)] == [
assert [*app.stream(None, config, checkpoint_during=checkpoint_during)] == [
{"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}},
{"inner": {"my_key": "got here and there"}},
{"outer_2": {"my_key": " and back again"}},
@@ -3946,11 +4174,22 @@ def test_nested_graph_interrupts_parallel(
# test stream values w/ nested interrupt
config = {"configurable": {"thread_id": "3"}}
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
assert [
*app.stream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [*app.stream(None, config, stream_mode="values")] == [
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -3959,15 +4198,28 @@ def test_nested_graph_interrupts_parallel(
# test interrupts BEFORE the parallel node
app = graph.compile(checkpointer=checkpointer, interrupt_before=["outer_1"])
config = {"configurable": {"thread_id": "4"}}
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
{"my_key": ""}
]
assert [
*app.stream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [{"my_key": ""}]
# while we're waiting for the node w/ interrupt inside to finish
assert [*app.stream(None, config, stream_mode="values")] == [
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [*app.stream(None, config, stream_mode="values")] == [
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -3976,24 +4228,43 @@ def test_nested_graph_interrupts_parallel(
# test interrupts AFTER the parallel node
app = graph.compile(checkpointer=checkpointer, interrupt_after=["outer_1"])
config = {"configurable": {"thread_id": "5"}}
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
assert [
*app.stream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [*app.stream(None, config, stream_mode="values")] == [
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
]
assert [*app.stream(None, config, stream_mode="values")] == [
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_doubly_nested_graph_interrupts(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Unsupported combo")
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
class State(TypedDict):
@@ -4047,11 +4318,13 @@ def test_doubly_nested_graph_interrupts(
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert app.invoke({"my_key": "my value"}, config, debug=True) == {
assert app.invoke(
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
) == {
"my_key": "hi my value",
}
assert app.invoke(None, config, debug=True) == {
assert app.invoke(None, config, checkpoint_during=checkpoint_during) == {
"my_key": "hi my value here and there and back again",
}
@@ -4060,12 +4333,14 @@ def test_doubly_nested_graph_interrupts(
config = {
"configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append}
}
assert [*app.stream({"my_key": "my value"}, config)] == [
assert [
*app.stream({"my_key": "my value"}, config, checkpoint_during=checkpoint_during)
] == [
{"parent_1": {"my_key": "hi my value"}},
{"__interrupt__": ()},
]
assert nodes == ["parent_1", "grandchild_1"]
assert [*app.stream(None, config)] == [
assert [*app.stream(None, config, checkpoint_during=checkpoint_during)] == [
{"child": {"my_key": "hi my value here and there"}},
{"parent_2": {"my_key": "hi my value here and there and back again"}},
]
@@ -4080,11 +4355,22 @@ def test_doubly_nested_graph_interrupts(
# test stream values w/ nested interrupt
config = {"configurable": {"thread_id": "3"}}
assert [*app.stream({"my_key": "my value"}, config, stream_mode="values")] == [
assert [
*app.stream(
{"my_key": "my value"},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": "my value"},
{"my_key": "hi my value"},
]
assert [*app.stream(None, config, stream_mode="values")] == [
assert [
*app.stream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": "hi my value"},
{"my_key": "hi my value here and there"},
{"my_key": "hi my value here and there and back again"},
@@ -5870,7 +6156,7 @@ def test_falsy_return_from_task(
falsy_task().result()
interrupt("test")
configurable = {"configurable": {"thread_id": str(uuid.uuid4())}}
configurable = {"configurable": {"thread_id": uuid.uuid4()}}
assert [
chunk for chunk in graph.stream({"a": 5}, configurable, stream_mode="debug")
] == [
@@ -5883,7 +6169,7 @@ def test_falsy_return_from_task(
"checkpoint_ns": "",
"thread_id": AnyStr(),
},
"metadata": configurable["configurable"],
"metadata": {},
"recursion_limit": 25,
"tags": [],
},
@@ -5891,7 +6177,6 @@ def test_falsy_return_from_task(
"parents": {},
"source": "input",
"step": -1,
"thread_id": AnyStr(),
"writes": {
"__start__": {
"a": 5,
@@ -5982,6 +6267,7 @@ def test_falsy_return_from_task(
"type": "task_result",
},
]
print(type(configurable["configurable"]["thread_id"]))
assert [
c
for c in graph.stream(Command(resume="123"), configurable, stream_mode="debug")
@@ -5995,7 +6281,7 @@ def test_falsy_return_from_task(
"checkpoint_ns": "",
"thread_id": AnyStr(),
},
"metadata": configurable["configurable"],
"metadata": {},
"recursion_limit": 25,
"tags": [],
},
@@ -6090,7 +6376,7 @@ def test_falsy_return_from_task(
"checkpoint_ns": "",
"thread_id": AnyStr(),
},
"metadata": configurable["configurable"],
"metadata": {},
"recursion_limit": 25,
"tags": [],
},
@@ -6098,7 +6384,6 @@ def test_falsy_return_from_task(
"parents": {},
"source": "loop",
"step": 0,
"thread_id": AnyStr(),
"writes": {
"falsy_task": False,
"graph": None,
@@ -6112,7 +6397,7 @@ def test_falsy_return_from_task(
"checkpoint_ns": "",
"thread_id": AnyStr(),
},
"metadata": configurable["configurable"],
"metadata": {},
"recursion_limit": 25,
"tags": [],
},
@@ -6882,6 +7167,52 @@ def test_tags_stream_mode_messages() -> None:
]
def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
def my_node(state):
return {"messages": HumanMessage(content="foo")}
def my_other_node(state):
return Command(update={"messages": HumanMessage(content="bar")})
graph = (
StateGraph(MessagesState)
.add_sequence([my_node, my_other_node])
.add_edge(START, "my_node")
.compile()
)
assert list(
graph.stream(
{
"messages": [],
},
stream_mode="messages",
)
) == [
(
_AnyIdHumanMessage(content="foo"),
{
"langgraph_step": 1,
"langgraph_node": "my_node",
"langgraph_triggers": ("branch:to:my_node",),
"langgraph_path": ("__pregel_pull", "my_node"),
"langgraph_checkpoint_ns": AnyStr("my_node:"),
},
),
(
_AnyIdHumanMessage(content="bar"),
{
"langgraph_step": 2,
"langgraph_node": "my_other_node",
"langgraph_triggers": ("branch:to:my_other_node",),
"langgraph_path": ("__pregel_pull", "my_other_node"),
"langgraph_checkpoint_ns": AnyStr("my_other_node:"),
},
),
]
def test_node_destinations() -> None:
class State(TypedDict):
foo: Annotated[str, operator.add]
@@ -6960,6 +7291,39 @@ def test_pydantic_none_state_update() -> None:
assert graph.invoke({"foo": ""}) == {"foo": None}
def test_pydantic_state_update_command() -> None:
from pydantic import BaseModel
class State(BaseModel):
foo: Optional[str]
def node_a(state: State) -> State:
return Command(update=State(foo=None))
graph = StateGraph(State).add_node(node_a).add_edge(START, "node_a").compile()
assert graph.invoke({"foo": ""}) == {"foo": None}
class State(BaseModel):
foo: Optional[str] = None
bar: Optional[str] = None
def node_a(state: State):
return State(foo="foo")
def node_b(state: State):
return Command(update=State(bar="bar"))
builder = StateGraph(State)
builder.add_node(node_a)
builder.add_node(node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
graph = builder.compile()
assert graph.invoke(State()) == {"foo": "foo", "bar": "bar"}
def test_pydantic_state_mutation() -> None:
from pydantic import BaseModel, Field
@@ -6994,6 +7358,40 @@ def test_pydantic_state_mutation() -> None:
assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)}
def test_pydantic_state_mutation_command() -> None:
from pydantic import BaseModel, Field
class Inner(BaseModel):
a: int = 0
class State(BaseModel):
inner: Inner = Inner()
outer: int = 0
def my_node(state: State) -> State:
state.inner.a = 5
state.outer = 10
return Command(update=state)
graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile()
assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)}
# test w/ default_factory
class State(BaseModel):
inner: Inner = Field(default_factory=Inner)
outer: int = 0
def my_node(state: State) -> State:
state.inner.a = 5
state.outer = 10
return Command(update=state)
graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile()
assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)}
def test_get_stream_writer() -> None:
class State(TypedDict):
foo: str
+395 -53
View File
@@ -1947,10 +1947,14 @@ async def test_invoke_checkpoint(mocker: MockerFixture, checkpointer_name: str)
assert checkpoint["channel_values"].get("total") == 5
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_pending_writes_resume(
request: pytest.FixtureRequest, checkpointer_name: str
checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
class State(TypedDict):
value: Annotated[int, operator.add]
@@ -1972,10 +1976,12 @@ async def test_pending_writes_resume(
self.calls = 0
one = AwhileMaker(0.1, {"value": 2})
two = AwhileMaker(0.3, ConnectionError("I'm not good"))
two = AwhileMaker(0.2, ConnectionError("I'm not good"))
builder = StateGraph(State)
builder.add_node("one", one)
builder.add_node("two", two, retry=RetryPolicy(max_attempts=2))
builder.add_node(
"two", two, retry=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False)
)
builder.add_edge(START, "one")
builder.add_edge(START, "two")
async with awith_checkpointer(checkpointer_name) as checkpointer:
@@ -1983,7 +1989,9 @@ async def test_pending_writes_resume(
thread1: RunnableConfig = {"configurable": {"thread_id": "1"}}
with pytest.raises(ConnectionError, match="I'm not good"):
await graph.ainvoke({"value": 1}, thread1)
await graph.ainvoke(
{"value": 1}, thread1, checkpoint_during=checkpoint_during
)
# both nodes should have been called once
assert one.calls == 1
@@ -2034,7 +2042,7 @@ async def test_pending_writes_resume(
# resume execution
with pytest.raises(ConnectionError, match="I'm not good"):
await graph.ainvoke(None, thread1)
await graph.ainvoke(None, thread1, checkpoint_during=checkpoint_during)
# node "one" succeeded previously, so shouldn't be called again
assert one.calls == 1
@@ -2048,7 +2056,9 @@ async def test_pending_writes_resume(
# resume execution, without exception
two.rtn = {"value": 3}
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
assert await graph.ainvoke(None, thread1) == {"value": 6}
assert await graph.ainvoke(
None, thread1, checkpoint_during=checkpoint_during
) == {"value": 6}
if "shallow" in checkpointer_name:
assert len([c async for c in checkpointer.alist(thread1)]) == 1
@@ -2057,7 +2067,7 @@ async def test_pending_writes_resume(
# check all final checkpoints
checkpoints = [c async for c in checkpointer.alist(thread1)]
# we should have 3
assert len(checkpoints) == 3
assert len(checkpoints) == (3 if checkpoint_during else 2)
# the last one not too interesting for this test
assert checkpoints[0] == CheckpointTuple(
config={
@@ -2163,15 +2173,26 @@ async def test_pending_writes_resume(
"checkpoint_ns": "",
"checkpoint_id": checkpoints[2].config["configurable"][
"checkpoint_id"
],
]
if checkpoint_during
else AnyStr(),
}
},
pending_writes=UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
(AnyStr(), "value", 3),
)
if checkpoint_during
else UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
# the write against the previous checkpoint is not saved, as it is
# produced in a run where only the next checkpoint (the last) is saved
),
)
if not checkpoint_during:
return
assert checkpoints[2] == CheckpointTuple(
config={
"configurable": {
@@ -2209,7 +2230,7 @@ async def test_pending_writes_resume(
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_run_from_checkpoint_id_retains_previous_writes(
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
checkpointer_name: str,
) -> None:
class MyState(TypedDict):
myval: Annotated[int, operator.add]
@@ -2254,8 +2275,8 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
history = [c async for c in graph.aget_state_history(thread1)]
assert len(history) == 4
assert history[-1].values == {"myval": 0}
assert history[0].values == {"myval": 4, "otherval": False}
assert history[-1].values == {"myval": 0}
second_run_config = {
**thread1,
@@ -2432,8 +2453,12 @@ async def test_send_sequences(checkpointer_name: str) -> None:
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_task(checkpointer_name: str) -> None:
async def test_imp_task(checkpointer_name: str, checkpoint_during: bool) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async with awith_checkpointer(checkpointer_name) as checkpointer:
mapper_calls = 0
@@ -2453,7 +2478,12 @@ async def test_imp_task(checkpointer_name: str) -> None:
tracer = FakeTracer()
thread1 = {"configurable": {"thread_id": "1"}, "callbacks": [tracer]}
assert [c async for c in graph.astream([0, 1], thread1)] == [
assert [
c
async for c in graph.astream(
[0, 1], thread1, checkpoint_during=checkpoint_during
)
] == [
{"mapper": "00"},
{"mapper": "11"},
{
@@ -2477,7 +2507,9 @@ async def test_imp_task(checkpointer_name: str) -> None:
assert any(r.inputs == {"input": 0} for r in mapper_runs)
assert any(r.inputs == {"input": 1} for r in mapper_runs)
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
assert await graph.ainvoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
"00answer",
"11answer",
]
@@ -2485,8 +2517,12 @@ async def test_imp_task(checkpointer_name: str) -> None:
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_nested(checkpointer_name: str) -> None:
async def test_imp_nested(checkpointer_name: str, checkpoint_during: bool) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async def mynode(input: list[str]) -> list[str]:
return [it + "a" for it in input]
@@ -2526,7 +2562,12 @@ async def test_imp_nested(checkpointer_name: str) -> None:
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [c async for c in graph.astream([0, 1], thread1)] == [
assert [
c
async for c in graph.astream(
[0, 1], thread1, checkpoint_during=checkpoint_during
)
] == [
{"submapper": "0"},
{"mapper": "00"},
{"submapper": "1"},
@@ -2543,15 +2584,21 @@ async def test_imp_nested(checkpointer_name: str) -> None:
},
]
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
assert await graph.ainvoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
"00answera",
"11answera",
]
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_task_cancel(checkpointer_name: str) -> None:
async def test_imp_task_cancel(checkpointer_name: str, checkpoint_during: bool) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async with awith_checkpointer(checkpointer_name) as checkpointer:
mapper_calls = 0
mapper_cancels = 0
@@ -2577,7 +2624,12 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
return [m + answer for m in mapped]
thread1 = {"configurable": {"thread_id": "1"}}
assert [c async for c in graph.astream([0, 1], thread1)] == [
assert [
c
async for c in graph.astream(
[0, 1], thread1, checkpoint_during=checkpoint_during
)
] == [
{"mapper": "00"},
{
"__interrupt__": (
@@ -2593,7 +2645,9 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
assert mapper_calls == 2
assert mapper_cancels == 1
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
assert await graph.ainvoke(
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
) == [
"00answer",
]
assert mapper_calls == 3
@@ -2601,8 +2655,14 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_sync_from_async(checkpointer_name: str) -> None:
async def test_imp_sync_from_async(
checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async with awith_checkpointer(checkpointer_name) as checkpointer:
@task()
@@ -2625,7 +2685,12 @@ async def test_imp_sync_from_async(checkpointer_name: str) -> None:
return fut_baz.result()
thread1 = {"configurable": {"thread_id": "1"}}
assert [c async for c in graph.astream({"a": "0"}, thread1)] == [
assert [
c
async for c in graph.astream(
{"a": "0"}, thread1, checkpoint_during=checkpoint_during
)
] == [
{"foo": {"a": "0foo", "b": "bar"}},
{"bar": {"a": "0foobar", "c": "bark"}},
{"baz": {"a": "0foobarbaz", "c": "something else"}},
@@ -2634,8 +2699,14 @@ async def test_imp_sync_from_async(checkpointer_name: str) -> None:
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_stream_order(checkpointer_name: str) -> None:
async def test_imp_stream_order(
checkpointer_name: str, checkpoint_during: bool
) -> None:
if not checkpoint_during and "shallow" in checkpointer_name:
pytest.skip("Checkpointing during execution not supported")
async with awith_checkpointer(checkpointer_name) as checkpointer:
@task()
@@ -2659,7 +2730,12 @@ async def test_imp_stream_order(checkpointer_name: str) -> None:
return await fut_baz
thread1 = {"configurable": {"thread_id": "1"}}
assert [c async for c in graph.astream({"a": "0"}, thread1)] == [
assert [
c
async for c in graph.astream(
{"a": "0"}, thread1, checkpoint_during=checkpoint_during
)
] == [
{"foo": {"a": "0foo", "b": "bar"}},
{"bar": {"a": "0foobar", "c": "bark"}},
{"baz": {"a": "0foobarbaz", "c": "something else"}},
@@ -2667,8 +2743,11 @@ async def test_imp_stream_order(checkpointer_name: str) -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
async def test_send_dedupe_on_resume(
checkpointer_name: str, checkpoint_during: bool
) -> None:
class InterruptOnce:
ticks: int = 0
@@ -2719,7 +2798,9 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
thread1 = {"configurable": {"thread_id": "1"}}
assert await graph.ainvoke(["0"], thread1, debug=1) == [
assert await graph.ainvoke(
["0"], thread1, checkpoint_during=checkpoint_during
) == [
"0",
"1",
"3.1",
@@ -2731,7 +2812,9 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
assert builder.nodes["2"].runnable.func.ticks == 3
assert builder.nodes["flaky"].runnable.func.ticks == 1
# resume execution
assert await graph.ainvoke(None, thread1, debug=1) == [
assert await graph.ainvoke(
None, thread1, checkpoint_during=checkpoint_during
) == [
"0",
"1",
"3.1",
@@ -2748,7 +2831,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
assert builder.nodes["flaky"].runnable.func.ticks == 2
# check history
history = [c async for c in graph.aget_state_history(thread1)]
assert history == [
assert len(history) == (6 if checkpoint_during else 2)
expected_history = [
StateSnapshot(
values=[
"0",
@@ -2884,13 +2968,9 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
name="flaky",
path=("__pregel_push", 1),
error=None,
interrupts=(
Interrupt(
value="Bahh", resumable=False, ns=None, when="during"
),
),
interrupts=(Interrupt(value="Bahh", resumable=False, ns=None),),
state=None,
result=["flaky|4"],
result=["flaky|4"] if checkpoint_during else None,
),
PregelTask(
id=AnyStr(),
@@ -3027,6 +3107,11 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
),
),
]
if checkpoint_during:
assert history == expected_history
else:
assert history[0] == expected_history[0]
assert history[1] == expected_history[2]
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
@@ -4553,6 +4638,7 @@ async def test_nested_pydantic_models(version: str) -> None:
optional_nested: Optional[NestedModel] = None
dict_nested: dict[str, NestedModel]
my_set: set[int]
another_set: set
my_enum: MyEnum
list_nested: Annotated[
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
@@ -4581,6 +4667,7 @@ async def test_nested_pydantic_models(version: str) -> None:
"nested": {"value": 42, "name": "test"},
"optional_nested": {"value": 10, "name": "optional"},
"my_set": [1, 2, 7],
"another_set": ["foo", 3],
"my_enum": MyEnum.B,
"my_typed_dict": {"x": 1, "my_enum": MyEnum.A},
"dict_nested": {"a": {"value": 5, "name": "a"}},
@@ -5348,6 +5435,132 @@ async def test_nested_graph(snapshot: SnapshotAssertion) -> None:
assert times_called == 1
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_subgraph_checkpoint_true(
checkpointer_name: str, checkpoint_during: bool
) -> None:
class InnerState(TypedDict):
my_key: Annotated[str, operator.add]
my_other_key: str
def inner_1(state: InnerState):
return {"my_key": " got here", "my_other_key": state["my_key"]}
def inner_2(state: InnerState):
return {"my_key": " and there"}
inner = StateGraph(InnerState)
inner.add_node("inner_1", inner_1)
inner.add_node("inner_2", inner_2)
inner.add_edge("inner_1", "inner_2")
inner.set_entry_point("inner_1")
inner.set_finish_point("inner_2")
class State(TypedDict):
my_key: str
graph = StateGraph(State)
graph.add_node("inner", inner.compile(checkpointer=True))
graph.add_edge(START, "inner")
graph.add_conditional_edges(
"inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END
)
async with awith_checkpointer(checkpointer_name) as checkpointer:
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "2"}}
assert [
c
async for c in app.astream(
{"my_key": ""},
config,
subgraphs=True,
checkpoint_during=checkpoint_during,
)
] == [
(("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}),
(("inner",), {"inner_2": {"my_key": " and there"}}),
((), {"inner": {"my_key": " got here and there"}}),
(
("inner",),
{
"inner_1": {
"my_key": " got here",
"my_other_key": " got here and there got here and there",
}
},
),
(("inner",), {"inner_2": {"my_key": " and there"}}),
(
(),
{
"inner": {
"my_key": " got here and there got here and there got here and there"
}
},
),
]
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_subgraph_checkpoint_true_interrupt(
checkpointer_name: str, checkpoint_during: bool
) -> None:
# Define subgraph
class SubgraphState(TypedDict):
# note that none of these keys are shared with the parent graph state
bar: str
baz: str
def subgraph_node_1(state: SubgraphState):
baz_value = interrupt("Provide baz value")
return {"baz": baz_value}
def subgraph_node_2(state: SubgraphState):
return {"bar": state["bar"] + state["baz"]}
subgraph_builder = StateGraph(SubgraphState)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
subgraph_builder.add_edge(START, "subgraph_node_1")
subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2")
subgraph = subgraph_builder.compile(checkpointer=True)
class ParentState(TypedDict):
foo: str
def node_1(state: ParentState):
return {"foo": "hi! " + state["foo"]}
async def node_2(state: ParentState, config: RunnableConfig):
response = await subgraph.ainvoke({"bar": state["foo"]})
return {"foo": response["bar"]}
builder = StateGraph(ParentState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
assert await graph.ainvoke(
{"foo": "foo"}, config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foo"}
assert (await graph.aget_state(config, subgraphs=True)).tasks[
0
].state.values == {"bar": "hi! foo"}
assert await graph.ainvoke(
Command(resume="baz"), config, checkpoint_during=checkpoint_during
) == {"foo": "hi! foobaz"}
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_stream_subgraphs_during_execution(checkpointer_name: str) -> None:
class InnerState(TypedDict):
@@ -5456,8 +5669,11 @@ async def test_stream_buffering_single_node(checkpointer_name: str) -> None:
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
async def test_nested_graph_interrupts_parallel(
checkpointer_name: str, checkpoint_during: bool
) -> None:
class InnerState(TypedDict):
my_key: Annotated[str, operator.add]
my_other_key: str
@@ -5506,11 +5722,13 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert await app.ainvoke({"my_key": ""}, config, debug=True) == {
assert await app.ainvoke(
{"my_key": ""}, config, checkpoint_during=checkpoint_during
) == {
"my_key": " and parallel",
}
assert await app.ainvoke(None, config, debug=True) == {
assert await app.ainvoke(None, config, checkpoint_during=checkpoint_during) == {
"my_key": "got here and there and parallel and back again",
}
@@ -5520,7 +5738,13 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
# test stream updates w/ nested interrupt
config = {"configurable": {"thread_id": "2"}}
assert [
c async for c in app.astream({"my_key": ""}, config, subgraphs=True)
c
async for c in app.astream(
{"my_key": ""},
config,
subgraphs=True,
checkpoint_during=checkpoint_during,
)
] == [
# we got to parallel node first
((), {"outer_1": {"my_key": " and parallel"}}),
@@ -5530,7 +5754,12 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
),
((), {"__interrupt__": ()}),
]
assert [c async for c in app.astream(None, config)] == [
assert [
c
async for c in app.astream(
None, config, checkpoint_during=checkpoint_during
)
] == [
{"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}},
{"inner": {"my_key": "got here and there"}},
{"outer_2": {"my_key": " and back again"}},
@@ -5539,12 +5768,23 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
# test stream values w/ nested interrupt
config = {"configurable": {"thread_id": "3"}}
assert [
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
c
async for c in app.astream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -5554,16 +5794,32 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
app = graph.compile(checkpointer=checkpointer, interrupt_before=["outer_1"])
config = {"configurable": {"thread_id": "4"}}
assert [
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
c
async for c in app.astream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": ""},
]
# while we're waiting for the node w/ interrupt inside to finish
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
@@ -5573,23 +5829,42 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
app = graph.compile(checkpointer=checkpointer, interrupt_after=["outer_1"])
config = {"configurable": {"thread_id": "5"}}
assert [
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
c
async for c in app.astream(
{"my_key": ""},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": ""},
{"my_key": " and parallel"},
]
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": ""},
{"my_key": "got here and there and parallel"},
]
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": "got here and there and parallel"},
{"my_key": "got here and there and parallel and back again"},
]
@pytest.mark.parametrize("checkpoint_during", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
async def test_doubly_nested_graph_interrupts(
checkpointer_name: str, checkpoint_during: bool
) -> None:
class State(TypedDict):
my_key: str
@@ -5642,11 +5917,13 @@ async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
# test invoke w/ nested interrupt
config = {"configurable": {"thread_id": "1"}}
assert await app.ainvoke({"my_key": "my value"}, config, debug=True) == {
assert await app.ainvoke(
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
) == {
"my_key": "hi my value",
}
assert await app.ainvoke(None, config, debug=True) == {
assert await app.ainvoke(None, config, checkpoint_during=checkpoint_during) == {
"my_key": "hi my value here and there and back again",
}
@@ -5655,12 +5932,22 @@ async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
config = {
"configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append}
}
assert [c async for c in app.astream({"my_key": "my value"}, config)] == [
assert [
c
async for c in app.astream(
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
)
] == [
{"parent_1": {"my_key": "hi my value"}},
{"__interrupt__": ()},
]
assert nodes == ["parent_1", "grandchild_1"]
assert [c async for c in app.astream(None, config)] == [
assert [
c
async for c in app.astream(
None, config, checkpoint_during=checkpoint_during
)
] == [
{"child": {"my_key": "hi my value here and there"}},
{"parent_2": {"my_key": "hi my value here and there and back again"}},
]
@@ -5678,13 +5965,21 @@ async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
assert [
c
async for c in app.astream(
{"my_key": "my value"}, config, stream_mode="values"
{"my_key": "my value"},
config,
stream_mode="values",
checkpoint_during=checkpoint_during,
)
] == [
{"my_key": "my value"},
{"my_key": "hi my value"},
]
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
assert [
c
async for c in app.astream(
None, config, stream_mode="values", checkpoint_during=checkpoint_during
)
] == [
{"my_key": "hi my value"},
{"my_key": "hi my value here and there"},
{"my_key": "hi my value here and there and back again"},
@@ -7599,6 +7894,53 @@ async def test_tags_stream_mode_messages() -> None:
]
async def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
async def my_node(state):
return {"messages": HumanMessage(content="foo")}
async def my_other_node(state):
return Command(update={"messages": HumanMessage(content="bar")})
graph = (
StateGraph(MessagesState)
.add_sequence([my_node, my_other_node])
.add_edge(START, "my_node")
.compile()
)
assert [
c
async for c in graph.astream(
{
"messages": [],
},
stream_mode="messages",
)
] == [
(
_AnyIdHumanMessage(content="foo"),
{
"langgraph_step": 1,
"langgraph_node": "my_node",
"langgraph_triggers": ("branch:to:my_node",),
"langgraph_path": ("__pregel_pull", "my_node"),
"langgraph_checkpoint_ns": AnyStr("my_node:"),
},
),
(
_AnyIdHumanMessage(content="bar"),
{
"langgraph_step": 2,
"langgraph_node": "my_other_node",
"langgraph_triggers": ("branch:to:my_other_node",),
"langgraph_path": ("__pregel_pull", "my_other_node"),
"langgraph_checkpoint_ns": AnyStr("my_other_node:"),
},
),
]
async def test_stream_messages_dedupe_inputs() -> None:
from langchain_core.messages import AIMessage
+101 -79
View File
@@ -437,15 +437,17 @@ def test_stream():
sync_client=mock_sync_client,
)
# stream modes doesn't include 'updates'
stream_parts = []
# test raising graph interrupt if invoked as a subgraph
with pytest.raises(GraphInterrupt) as exc:
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
# pretend we invoked this as a subgraph
config={
"configurable": {"thread_id": "thread_1", "checkpoint_ns": "some_ns"}
},
stream_mode="values",
):
stream_parts.append(stream_part)
pass
assert exc.value.args[0] == [
Interrupt(
@@ -456,6 +458,15 @@ def test_stream():
)
]
# stream modes doesn't include 'updates'
stream_parts = []
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode="values",
):
stream_parts.append(stream_part)
assert stream_parts == [
{"chunk": "data1"},
{"chunk": "data2"},
@@ -470,62 +481,62 @@ def test_stream():
# default stream_mode is updates
stream_parts = []
with pytest.raises(GraphInterrupt):
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
):
stream_parts.append(stream_part)
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
):
stream_parts.append(stream_part)
assert stream_parts == [
{"chunk": "data3"},
{"chunk": "data4"},
{"__interrupt__": ()},
]
# list stream_mode includes mode names
stream_parts = []
with pytest.raises(GraphInterrupt):
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
):
stream_parts.append(stream_part)
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
):
stream_parts.append(stream_part)
assert stream_parts == [
("updates", {"chunk": "data3"}),
("updates", {"chunk": "data4"}),
("updates", {"__interrupt__": ()}),
]
# subgraphs + list modes
stream_parts = []
with pytest.raises(GraphInterrupt):
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
subgraphs=True,
):
stream_parts.append(stream_part)
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
subgraphs=True,
):
stream_parts.append(stream_part)
assert stream_parts == [
((), "updates", {"chunk": "data3"}),
((), "updates", {"chunk": "data4"}),
((), "updates", {"__interrupt__": ()}),
]
# subgraphs + single mode
stream_parts = []
with pytest.raises(GraphInterrupt):
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
subgraphs=True,
):
stream_parts.append(stream_part)
for stream_part in remote_pregel.stream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
subgraphs=True,
):
stream_parts.append(stream_part)
assert stream_parts == [
((), {"chunk": "data3"}),
((), {"chunk": "data4"}),
((), {"__interrupt__": ()}),
]
@@ -561,15 +572,17 @@ async def test_astream():
client=mock_async_client,
)
# stream modes doesn't include 'updates'
stream_parts = []
# test raising graph interrupt if invoked as a subgraph
with pytest.raises(GraphInterrupt) as exc:
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
# pretend we invoked this as a subgraph
config={
"configurable": {"thread_id": "thread_1", "checkpoint_ns": "some_ns"}
},
stream_mode="values",
):
stream_parts.append(stream_part)
pass
assert exc.value.args[0] == [
Interrupt(
@@ -580,6 +593,15 @@ async def test_astream():
)
]
# stream modes doesn't include 'updates'
stream_parts = []
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode="values",
):
stream_parts.append(stream_part)
assert stream_parts == [
{"chunk": "data1"},
{"chunk": "data2"},
@@ -596,62 +618,62 @@ async def test_astream():
# default stream_mode is updates
stream_parts = []
with pytest.raises(GraphInterrupt):
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
):
stream_parts.append(stream_part)
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
):
stream_parts.append(stream_part)
assert stream_parts == [
{"chunk": "data3"},
{"chunk": "data4"},
{"__interrupt__": ()},
]
# list stream_mode includes mode names
stream_parts = []
with pytest.raises(GraphInterrupt):
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
):
stream_parts.append(stream_part)
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
):
stream_parts.append(stream_part)
assert stream_parts == [
("updates", {"chunk": "data3"}),
("updates", {"chunk": "data4"}),
("updates", {"__interrupt__": ()}),
]
# subgraphs + list modes
stream_parts = []
with pytest.raises(GraphInterrupt):
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
subgraphs=True,
):
stream_parts.append(stream_part)
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
subgraphs=True,
):
stream_parts.append(stream_part)
assert stream_parts == [
((), "updates", {"chunk": "data3"}),
((), "updates", {"chunk": "data4"}),
((), "updates", {"__interrupt__": ()}),
]
# subgraphs + single mode
stream_parts = []
with pytest.raises(GraphInterrupt):
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
subgraphs=True,
):
stream_parts.append(stream_part)
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
subgraphs=True,
):
stream_parts.append(stream_part)
assert stream_parts == [
((), {"chunk": "data3"}),
((), {"chunk": "data4"}),
((), {"__interrupt__": ()}),
]
async_iter = MagicMock()
@@ -664,33 +686,33 @@ async def test_astream():
# subgraphs + list modes
stream_parts = []
with pytest.raises(GraphInterrupt):
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
subgraphs=True,
):
stream_parts.append(stream_part)
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
stream_mode=["updates"],
subgraphs=True,
):
stream_parts.append(stream_part)
assert stream_parts == [
(("my", "subgraph"), "updates", {"chunk": "data3"}),
(("hello", "subgraph"), "updates", {"chunk": "data4"}),
(("bye", "subgraph"), "updates", {"__interrupt__": ()}),
]
# subgraphs + single mode
stream_parts = []
with pytest.raises(GraphInterrupt):
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
subgraphs=True,
):
stream_parts.append(stream_part)
async for stream_part in remote_pregel.astream(
{"input": "data"},
config={"configurable": {"thread_id": "thread_1"}},
subgraphs=True,
):
stream_parts.append(stream_part)
assert stream_parts == [
(("my", "subgraph"), {"chunk": "data3"}),
(("hello", "subgraph"), {"chunk": "data4"}),
(("bye", "subgraph"), {"__interrupt__": ()}),
]
+342
View File
@@ -0,0 +1,342 @@
from unittest.mock import Mock, patch
import pytest
from typing_extensions import TypedDict
from langgraph.graph import START, StateGraph
from langgraph.pregel.retry import _should_retry_on
from langgraph.types import RetryPolicy
def test_should_retry_on_single_exception():
"""Test retry with a single exception type."""
policy = RetryPolicy(retry_on=ValueError)
# Should retry on ValueError
assert _should_retry_on(policy, ValueError("test error")) is True
# Should not retry on other exceptions
assert _should_retry_on(policy, TypeError("test error")) is False
assert _should_retry_on(policy, Exception("test error")) is False
def test_should_retry_on_sequence_of_exceptions():
"""Test retry with a sequence of exception types."""
policy = RetryPolicy(retry_on=(ValueError, KeyError))
# Should retry on listed exceptions
assert _should_retry_on(policy, ValueError("test error")) is True
assert _should_retry_on(policy, KeyError("test error")) is True
# Should not retry on other exceptions
assert _should_retry_on(policy, TypeError("test error")) is False
assert _should_retry_on(policy, Exception("test error")) is False
def test_should_retry_on_subclass_of_exception():
"""Test retry on subclass of specified exception."""
class CustomError(ValueError):
pass
policy = RetryPolicy(retry_on=ValueError)
# Should retry on subclass of specified exception
assert _should_retry_on(policy, CustomError("test error")) is True
def test_should_retry_on_callable():
"""Test retry with a callable predicate."""
# Only retry on ValueError with message containing 'retry'
def should_retry(exc: Exception) -> bool:
return isinstance(exc, ValueError) and "retry" in str(exc)
policy = RetryPolicy(retry_on=should_retry)
# Should retry when predicate returns True
assert _should_retry_on(policy, ValueError("please retry this")) is True
# Should not retry when predicate returns False
assert _should_retry_on(policy, ValueError("other error")) is False
assert _should_retry_on(policy, TypeError("please retry this")) is False
def test_should_retry_on_invalid_type():
"""Test retry with an invalid retry_on type."""
policy = RetryPolicy(retry_on=123) # type: ignore
with pytest.raises(TypeError, match="retry_on must be an Exception class"):
_should_retry_on(policy, ValueError("test error"))
def test_should_retry_on_empty_sequence():
"""Test retry with an empty sequence."""
policy = RetryPolicy(retry_on=())
# Should not retry when sequence is empty
assert _should_retry_on(policy, ValueError("test error")) is False
def test_should_retry_default_retry_on():
"""Test the default retry_on function."""
import httpx
import requests
# Create a RetryPolicy with default_retry_on
policy = RetryPolicy()
# Should retry on ConnectionError
assert _should_retry_on(policy, ConnectionError("connection refused")) is True
# Should not retry on common programming errors
assert _should_retry_on(policy, ValueError("invalid value")) is False
assert _should_retry_on(policy, TypeError("invalid type")) is False
assert _should_retry_on(policy, ArithmeticError("division by zero")) is False
assert _should_retry_on(policy, ImportError("module not found")) is False
assert _should_retry_on(policy, LookupError("key not found")) is False
assert _should_retry_on(policy, NameError("name not defined")) is False
assert _should_retry_on(policy, SyntaxError("invalid syntax")) is False
assert _should_retry_on(policy, RuntimeError("runtime error")) is False
assert _should_retry_on(policy, ReferenceError("weak reference")) is False
assert _should_retry_on(policy, StopIteration()) is False
assert _should_retry_on(policy, StopAsyncIteration()) is False
assert _should_retry_on(policy, OSError("file not found")) is False
# Should retry on httpx.HTTPStatusError with 5xx status code
response_5xx = Mock()
response_5xx.status_code = 503
http_error_5xx = httpx.HTTPStatusError(
"server error", request=Mock(), response=response_5xx
)
assert _should_retry_on(policy, http_error_5xx) is True
# Should not retry on httpx.HTTPStatusError with 4xx status code
response_4xx = Mock()
response_4xx.status_code = 404
http_error_4xx = httpx.HTTPStatusError(
"not found", request=Mock(), response=response_4xx
)
assert _should_retry_on(policy, http_error_4xx) is False
# Should retry on requests.HTTPError with 5xx status code
response_req_5xx = Mock()
response_req_5xx.status_code = 502
req_error_5xx = requests.HTTPError("bad gateway")
req_error_5xx.response = response_req_5xx
assert _should_retry_on(policy, req_error_5xx) is True
# Should not retry on requests.HTTPError with 4xx status code
response_req_4xx = Mock()
response_req_4xx.status_code = 400
req_error_4xx = requests.HTTPError("bad request")
req_error_4xx.response = response_req_4xx
assert _should_retry_on(policy, req_error_4xx) is False
# Should retry on requests.HTTPError with no response
req_error_no_resp = requests.HTTPError("connection error")
req_error_no_resp.response = None
assert _should_retry_on(policy, req_error_no_resp) is True
# Should retry on other exceptions by default
class CustomException(Exception):
pass
assert _should_retry_on(policy, CustomException("custom error")) is True
def test_graph_with_single_retry_policy():
"""Test a simple graph with a single RetryPolicy for a node."""
class State(TypedDict):
foo: str
attempt_count = 0
def failing_node(state: State):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3: # Fail the first two attempts
raise ValueError("Intentional failure")
return {"foo": "success"}
def other_node(state: State):
return {"foo": "other_node"}
# Create a retry policy with specific parameters
retry_policy = RetryPolicy(
max_attempts=3,
initial_interval=0.01, # Short interval for tests
backoff_factor=2.0,
jitter=False, # Disable jitter for predictable timing
retry_on=ValueError,
)
# Create and compile the graph
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry=retry_policy)
.add_node("other_node", other_node)
.add_edge(START, "failing_node")
.add_edge("failing_node", "other_node")
.compile()
)
with patch("time.sleep") as mock_sleep:
result = graph.invoke({"foo": ""})
# Verify retry behavior
assert attempt_count == 3 # The node should have been tried 3 times
assert result["foo"] == "other_node" # Final result should be from other_node
# Verify the sleep intervals
call_args_list = [args[0][0] for args in mock_sleep.call_args_list]
assert call_args_list == [0.01, 0.02]
def test_graph_with_jitter_retry_policy():
"""Test a graph with a RetryPolicy that uses jitter."""
class State(TypedDict):
foo: str
attempt_count = 0
def failing_node(state):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 2: # Fail the first attempt
raise ValueError("Intentional failure")
return {"foo": "success"}
# Create a retry policy with jitter enabled
retry_policy = RetryPolicy(
max_attempts=3,
initial_interval=0.01,
jitter=True, # Enable jitter for randomized backoff
retry_on=ValueError,
)
# Create and compile the graph
graph = (
StateGraph(State)
.add_node("failing_node", failing_node, retry=retry_policy)
.add_edge(START, "failing_node")
.compile()
)
# Test graph execution with mocked random and sleep
with patch("random.uniform", return_value=0.05) as mock_random, patch(
"time.sleep"
) as mock_sleep:
result = graph.invoke({"foo": ""})
# Verify retry behavior
assert attempt_count == 2 # The node should have been tried twice
assert result["foo"] == "success"
# Verify jitter was applied
mock_random.assert_called_with(0, 1) # Jitter should use random.uniform(0, 1)
mock_sleep.assert_called_with(0.01 + 0.05) # Sleep should include jitter
def test_graph_with_multiple_retry_policies():
"""Test a graph with multiple retry policies for a node."""
class State(TypedDict):
foo: str
error_type: str
attempt_counts = {"value_error": 0, "key_error": 0}
def failing_node(state):
error_type = state["error_type"]
if error_type == "value_error":
attempt_counts["value_error"] += 1
if attempt_counts["value_error"] < 2:
raise ValueError("Value error")
elif error_type == "key_error":
attempt_counts["key_error"] += 1
if attempt_counts["key_error"] < 3:
raise KeyError("Key error")
return {"foo": f"recovered_from_{error_type}"}
# Create multiple retry policies
value_error_policy = RetryPolicy(
max_attempts=2,
initial_interval=0.01,
jitter=False,
retry_on=ValueError,
)
key_error_policy = RetryPolicy(
max_attempts=3,
initial_interval=0.02,
jitter=False,
retry_on=KeyError,
)
# Create and compile the graph with a list of retry policies
graph = (
StateGraph(State)
.add_node(
"failing_node",
failing_node,
retry=(value_error_policy, key_error_policy),
)
.add_edge(START, "failing_node")
.compile()
)
# Test ValueError scenario
with patch("time.sleep"):
result_value_error = graph.invoke({"foo": "", "error_type": "value_error"})
assert attempt_counts["value_error"] == 2
assert result_value_error["foo"] == "recovered_from_value_error"
# Reset attempt counts
attempt_counts = {"value_error": 0, "key_error": 0}
# Test KeyError scenario
with patch("time.sleep"):
result_key_error = graph.invoke({"foo": "", "error_type": "key_error"})
assert attempt_counts["key_error"] == 3
assert result_key_error["foo"] == "recovered_from_key_error"
def test_graph_with_max_attempts_exceeded():
"""Test a graph where max_attempts is exceeded."""
class State(TypedDict):
foo: str
def always_failing_node(state):
raise ValueError("Always fails")
# Create a retry policy with limited attempts
retry_policy = RetryPolicy(
max_attempts=2,
initial_interval=0.01,
jitter=False,
retry_on=ValueError,
)
# Create and compile the graph
graph = (
StateGraph(State)
.add_node("always_failing", always_failing_node, retry=retry_policy)
.add_edge(START, "always_failing")
.compile()
)
# Test graph execution
with patch("time.sleep") as mock_sleep, pytest.raises(
ValueError, match="Always fails"
):
graph.invoke({"foo": ""})
mock_sleep.assert_called_with(0.01)
+4
View File
@@ -6,6 +6,10 @@ client.cjs
client.js
client.d.ts
client.d.cts
auth.cjs
auth.js
auth.d.ts
auth.d.cts
react.cjs
react.js
react.d.ts
+1
View File
@@ -14,6 +14,7 @@ export const config = {
entrypoints: {
index: "index",
client: "client",
auth: "auth/index",
react: "react/index",
"react-ui": "react-ui/index",
"react-ui/server": "react-ui/server/index",
+15 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.62",
"version": "0.0.67",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
@@ -11,7 +11,7 @@
"format": "prettier --write src",
"lint": "prettier --check src && tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts",
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json"
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json && typedoc src/auth/index.ts --out docs/auth --options typedoc.auth.json"
},
"main": "index.js",
"license": "MIT",
@@ -72,6 +72,15 @@
"import": "./client.js",
"require": "./client.cjs"
},
"./auth": {
"types": {
"import": "./auth.d.ts",
"require": "./auth.d.cts",
"default": "./auth.d.ts"
},
"import": "./auth.js",
"require": "./auth.cjs"
},
"./react": {
"types": {
"import": "./react.d.ts",
@@ -111,6 +120,10 @@
"client.js",
"client.d.ts",
"client.d.cts",
"auth.cjs",
"auth.js",
"auth.d.ts",
"auth.d.cts",
"react.cjs",
"react.js",
"react.d.ts",
+80
View File
@@ -0,0 +1,80 @@
const HTTP_STATUS_MAPPING: { [key: number]: string } = {
100: "Continue",
101: "Switching Protocols",
102: "Processing",
103: "Early Hints",
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Request Entity Too Large",
414: "Request-URI Too Long",
415: "Unsupported Media Type",
416: "Requested Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a Teapot",
421: "Misdirected Request",
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required",
};
export class HTTPException extends Error {
status: number;
headers: HeadersInit;
constructor(
status: number,
options?: { message?: string; headers?: HeadersInit; cause?: unknown },
) {
super(options?.message ?? HTTP_STATUS_MAPPING[status] ?? "Unknown error", {
cause: options?.cause,
});
this.status = status;
this.headers = options?.headers ?? {};
}
}
+46
View File
@@ -0,0 +1,46 @@
import type {
AuthenticateCallback,
AnyCallback,
CallbackEvent,
OnCallback,
BaseAuthReturn,
ToUserLike,
BaseUser,
} from "./types.js";
export class Auth<
TExtra = {},
TAuthReturn extends BaseAuthReturn = BaseAuthReturn,
TUser extends BaseUser = ToUserLike<TAuthReturn>,
> {
/**
* @internal
* @ignore
*/
"~handlerCache": {
authenticate?: AuthenticateCallback<BaseAuthReturn>;
callbacks?: Record<string, AnyCallback>;
} = {};
authenticate<T extends BaseAuthReturn>(
cb: AuthenticateCallback<T>,
): Auth<TExtra, T> {
this["~handlerCache"].authenticate = cb;
return this as unknown as Auth<TExtra, T>;
}
on<T extends CallbackEvent>(event: T, callback: OnCallback<T, TUser>): this {
this["~handlerCache"].callbacks ??= {};
const events: string[] = Array.isArray(event) ? event : [event];
for (const event of events) {
this["~handlerCache"].callbacks[event] = callback as AnyCallback;
}
return this;
}
}
export type {
Filters as AuthFilters,
EventValueMap as AuthEventValueMap,
} from "./types.js";
export { HTTPException } from "./error.js";
+411
View File
@@ -0,0 +1,411 @@
type Maybe<T> = T | null | undefined;
type PromiseMaybe<T> = Promise<T> | T;
interface AssistantConfig {
tags?: Maybe<string[]>;
recursion_limit?: Maybe<number>;
configurable?: Maybe<{
thread_id?: Maybe<string>;
thread_ts?: Maybe<string>;
[key: string]: unknown;
}>;
}
/**
* @inline
*/
interface AssistantCreate {
assistant_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
config?: Maybe<AssistantConfig>;
if_exists?: Maybe<"raise" | "do_nothing">;
name?: Maybe<string>;
graph_id: string;
}
/**
* @inline
*/
interface AssistantRead {
assistant_id: string;
metadata?: Maybe<Record<string, unknown>>;
}
/**
* @inline
*/
interface AssistantUpdate {
assistant_id: string;
metadata?: Maybe<Record<string, unknown>>;
config?: Maybe<AssistantConfig>;
graph_id?: Maybe<string>;
name?: Maybe<string>;
version?: Maybe<number>;
}
/**
* @inline
*/
interface AssistantDelete {
assistant_id: string;
}
/**
* @inline
*/
interface AssistantSearch {
graph_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface ThreadCreate {
thread_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
if_exists?: Maybe<"raise" | "do_nothing">;
}
/**
* @inline
*/
interface ThreadRead {
thread_id?: Maybe<string>;
}
/**
* @inline
*/
interface ThreadUpdate {
thread_id?: Maybe<string>;
metadata?: Maybe<Record<string, unknown>>;
action?: Maybe<"interrupt" | "rollback">;
}
/**
* @inline
*/
interface ThreadDelete {
thread_id?: Maybe<string>;
run_id?: Maybe<string>;
}
/**
* @inline
*/
interface ThreadSearch {
thread_id?: Maybe<string>;
status?: Maybe<"idle" | "busy" | "interrupted" | "error" | (string & {})>;
metadata?: Maybe<Record<string, unknown>>;
values?: Maybe<Record<string, unknown>>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface CronCreate {
payload?: Maybe<Record<string, unknown>>;
schedule: string;
cron_id?: Maybe<string>;
thread_id?: Maybe<string>;
user_id?: Maybe<string>;
end_time?: Maybe<string>;
}
/**
* @inline
*/
interface CronRead {
cron_id: string;
}
/**
* @inline
*/
interface CronUpdate {
cron_id: string;
payload?: Maybe<Record<string, unknown>>;
schedule?: Maybe<string>;
}
/**
* @inline
*/
interface CronDelete {
cron_id: string;
}
/**
* @inline
*/
interface CronSearch {
assistant_id?: Maybe<string>;
thread_id?: Maybe<string>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface StorePut {
namespace: string[];
key: string;
value: Record<string, unknown>;
}
/**
* @inline
*/
interface StoreGet {
namespace: Maybe<string[]>;
key: string;
}
/**
* @inline
*/
interface StoreSearch {
namespace?: Maybe<string[]>;
filter?: Maybe<Record<string, unknown>>;
limit?: Maybe<number>;
offset?: Maybe<number>;
query?: Maybe<string>;
}
/**
* @inline
*/
interface StoreListNamespaces {
namespace?: Maybe<string[]>;
suffix?: Maybe<string[]>;
max_depth?: Maybe<number>;
limit?: Maybe<number>;
offset?: Maybe<number>;
}
/**
* @inline
*/
interface StoreDelete {
namespace?: Maybe<string[]>;
key: string;
}
/**
* @inline
*/
interface RunsCreate {
thread_id?: Maybe<string>;
assistant_id: string;
run_id: string;
status: Maybe<
"pending" | "running" | "error" | "success" | "timeout" | "interrupted"
>;
metadata?: Maybe<Record<string, unknown>>;
prevent_insert_if_inflight?: Maybe<boolean>;
multitask_strategy?: Maybe<"interrupt" | "rollback" | "reject" | "enqueue">;
if_not_exists?: Maybe<"reject" | "create">;
after_seconds?: Maybe<number>;
kwargs: Record<string, unknown>;
}
export interface EventValueMap {
["threads:create"]: ThreadCreate;
["threads:read"]: ThreadRead;
["threads:update"]: ThreadUpdate;
["threads:delete"]: ThreadDelete;
["threads:search"]: ThreadSearch;
["threads:create_run"]: RunsCreate;
["assistants:create"]: AssistantCreate;
["assistants:read"]: AssistantRead;
["assistants:update"]: AssistantUpdate;
["assistants:delete"]: AssistantDelete;
["assistants:search"]: AssistantSearch;
["crons:create"]: CronCreate;
["crons:read"]: CronRead;
["crons:update"]: CronUpdate;
["crons:delete"]: CronDelete;
["crons:search"]: CronSearch;
["store:put"]: StorePut;
["store:get"]: StoreGet;
["store:search"]: StoreSearch;
["store:list_namespaces"]: StoreListNamespaces;
["store:delete"]: StoreDelete;
}
interface ResourceType {
threads:
| "threads:create"
| "threads:read"
| "threads:update"
| "threads:delete"
| "threads:search"
| "threads:create_run";
assistants:
| "assistants:create"
| "assistants:read"
| "assistants:update"
| "assistants:delete"
| "assistants:search";
crons:
| "crons:create"
| "crons:read"
| "crons:update"
| "crons:delete"
| "crons:search";
store:
| "store:put"
| "store:get"
| "store:search"
| "store:list_namespaces"
| "store:delete";
}
interface ActionType {
"*:create": "threads:create" | "assistants:create" | "crons:create";
"*:read": "threads:read" | "assistants:read" | "crons:read";
"*:update": "threads:update" | "assistants:update" | "crons:update";
"*:delete":
| "threads:delete"
| "assistants:delete"
| "crons:delete"
| "store:delete";
"*:search":
| "threads:search"
| "assistants:search"
| "crons:search"
| "store:search";
"*:create_run": "threads:create_run";
"*:put": "store:put";
"*:get": "store:get";
"*:list_namespaces": "store:list_namespaces";
}
export type BaseAuthReturn =
| {
is_authenticated?: boolean;
display_name?: string;
identity: string;
permissions: string[];
}
| string;
export interface BaseUser {
is_authenticated: boolean;
display_name: string;
identity: string;
permissions: string[];
}
export type ToUserLike<T extends BaseAuthReturn> = T extends string
? {
is_authenticated: boolean;
display_name: string;
identity: string;
permissions: string[];
}
: Omit<T, "is_authenticated" | "display_name"> & {
is_authenticated: boolean;
display_name: string;
};
type CallbackParameter<
Event extends string = string,
Resource extends string = string,
Action extends string = string,
Value extends unknown = unknown,
TUser extends BaseUser = BaseUser,
> = {
event: Event;
resource: Resource;
action: Action;
value: Value;
user: TUser;
permissions: string[];
};
type ContextMap = {
[EventType in keyof EventValueMap]: CallbackParameter<
EventType,
EventType extends `${infer Resource}:${string}` ? Resource : never,
EventType extends `${string}:${infer Action}` ? Action : never,
EventValueMap[EventType],
BaseUser
>;
};
type ActionCallbackParameter<
T extends keyof ActionType,
TUser extends BaseUser = BaseUser,
> = ContextMap[ActionType[T]] & { user: TUser };
type AuthCallbackParameter<
T extends keyof EventValueMap,
TUser extends BaseUser = BaseUser,
> = ContextMap[T] & { user: TUser };
type ResourceCallbackParameter<
T extends keyof ResourceType,
TUser extends BaseUser = BaseUser,
> = ContextMap[ResourceType[T]] & { user: TUser };
export type Filters<TKey extends string | number | symbol> = {
[key in TKey]: string | { [op in "$contains" | "$eq"]?: string };
};
export interface AuthenticateCallback<T extends BaseAuthReturn> {
(request: Request): PromiseMaybe<T>;
}
type OnKey = keyof ResourceType | keyof ActionType | keyof EventValueMap;
type OnSingleParameter<
T extends OnKey,
TUser extends BaseUser = BaseUser,
> = T extends keyof ResourceType
? ResourceCallbackParameter<T, TUser>
: T extends keyof ActionType
? ActionCallbackParameter<T, TUser>
: T extends keyof EventValueMap
? AuthCallbackParameter<T, TUser>
: never;
type OnParameter<
T extends "*" | OnKey | OnKey[],
TUser extends BaseUser = BaseUser,
> = T extends OnKey[]
? OnSingleParameter<T[number], TUser>
: T extends "*"
? AuthCallbackParameter<keyof EventValueMap, TUser>
: T extends OnKey
? OnSingleParameter<T, TUser>
: never;
export type AnyCallback = (
request: CallbackParameter,
) => void | boolean | Filters<string>;
export type CallbackEvent = "*" | OnKey | OnKey[];
export type OnCallback<
T extends CallbackEvent,
TUser extends BaseUser = BaseUser,
TMetadata extends Record<string, unknown> = Record<string, unknown>,
> = (
request: OnParameter<T, TUser>,
) => void | boolean | Filters<keyof TMetadata>;
+26 -3
View File
@@ -84,18 +84,37 @@ class BaseClient {
protected defaultHeaders: Record<string, string | null | undefined>;
constructor(config?: ClientConfig) {
this.asyncCaller = new AsyncCaller({
const callerOptions = {
maxRetries: 4,
maxConcurrency: 4,
...config?.callerOptions,
});
};
let defaultApiUrl = "http://localhost:8123";
if (
!config?.apiUrl &&
typeof globalThis === "object" &&
globalThis != null
) {
const fetchSmb = Symbol.for("langgraph_api:fetch");
const urlSmb = Symbol.for("langgraph_api:url");
const global = globalThis as unknown as {
[fetchSmb]?: typeof fetch;
[urlSmb]?: string;
};
if (global[fetchSmb]) callerOptions.fetch ??= global[fetchSmb];
if (global[urlSmb]) defaultApiUrl = global[urlSmb];
}
this.asyncCaller = new AsyncCaller(callerOptions);
this.timeoutMs = config?.timeoutMs;
// default limit being capped by Chrome
// https://github.com/nodejs/undici/issues/1373
// Regex to remove trailing slash, if present
this.apiUrl = config?.apiUrl?.replace(/\/$/, "") || "http://localhost:8123";
this.apiUrl = config?.apiUrl?.replace(/\/$/, "") || defaultApiUrl;
this.defaultHeaders = config?.defaultHeaders || {};
const apiKey = getApiKey(config?.apiKey);
if (apiKey) {
@@ -340,6 +359,7 @@ export class AssistantsClient extends BaseClient {
assistantId?: string;
ifExists?: OnConflictBehavior;
name?: string;
description?: string;
}): Promise<Assistant> {
return this.fetch<Assistant>("/assistants", {
method: "POST",
@@ -350,6 +370,7 @@ export class AssistantsClient extends BaseClient {
assistant_id: payload.assistantId,
if_exists: payload.ifExists,
name: payload.name,
description: payload.description,
},
});
}
@@ -367,6 +388,7 @@ export class AssistantsClient extends BaseClient {
config?: Config;
metadata?: Metadata;
name?: string;
description?: string;
},
): Promise<Assistant> {
return this.fetch<Assistant>(`/assistants/${assistantId}`, {
@@ -376,6 +398,7 @@ export class AssistantsClient extends BaseClient {
config: payload.config,
metadata: payload.metadata,
name: payload.name,
description: payload.description,
},
});
}
+6 -1
View File
@@ -1 +1,6 @@
export { useStream, type MessageMetadata } from "./stream.js";
export {
useStream,
type MessageMetadata,
type UseStream,
type UseStreamOptions,
} from "./stream.js";
+1 -1
View File
@@ -405,7 +405,7 @@ type GetCustomEventType<Bag extends BagTemplate> = Bag extends {
? Bag["CustomEventType"]
: unknown;
interface UseStreamOptions<
export interface UseStreamOptions<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate,
> {
+3
View File
@@ -113,6 +113,9 @@ export interface AssistantBase {
/** The name of the assistant */
name: string;
/** The description of the assistant */
description?: string;
}
export interface AssistantVersion extends AssistantBase {}
+1 -5
View File
@@ -12,10 +12,8 @@ const STATUS_NO_RETRY = [
406, // Not Acceptable
407, // Proxy Authentication Required
408, // Request Timeout
422, // Unprocessable Entity
];
const STATUS_IGNORE = [
409, // Conflict
422, // Unprocessable Entity
];
type ResponseCallback = (response?: Response) => Promise<boolean>;
@@ -171,8 +169,6 @@ export class AsyncCaller {
if (error instanceof HTTPError) {
if (STATUS_NO_RETRY.includes(error.status)) {
throw error;
} else if (STATUS_IGNORE.includes(error.status)) {
return;
}
if (onFailedResponseHook && error.response) {
await onFailedResponseHook(error.response);
+5 -19
View File
@@ -2,11 +2,7 @@
"extends": "@tsconfig/recommended",
"compilerOptions": {
"target": "ES2021",
"lib": [
"ES2021",
"ES2022.Object",
"DOM"
],
"lib": ["ES2021", "ES2022.Object", "ES2022.Error", "DOM"],
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
@@ -22,24 +18,14 @@
"jsx": "react-jsx",
"outDir": "dist"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"coverage"
],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "coverage"],
"includeVersion": true,
"typedocOptions": {
"entryPoints": [
"src/client.ts"
],
"entryPoints": ["src/client.ts"],
"readme": "none",
"out": "docs",
"plugin": [
"typedoc-plugin-markdown"
],
"plugin": ["typedoc-plugin-markdown"],
"excludePrivate": true,
"excludeProtected": true,
"excludeExternals": false
+5
View File
@@ -0,0 +1,5 @@
{
"pageTitleTemplates": {
"index": "{projectName}/auth"
}
}