Compare commits

...
260 Commits
Author SHA1 Message Date
William FHandGitHub 1b4f335ffe Add studio URL flag (#3980) 2025-03-21 11:23:05 -07:00
William Fu-Hinthorn 9c36a7de85 Add studio URL flag 2025-03-21 11:20:29 -07:00
Vadym BardaandGitHub 8e922b859c prebuilt: release 0.1.4 (#3977) 2025-03-21 12:05:15 -04:00
Vadym BardaandGitHub 9a4c30135f prebuilt: pass last message to structured response model in create_react_agent (#3976) 2025-03-21 11:58:27 -04:00
William FHandGitHub bc6651f34c Studio cli command (#3962) 2025-03-20 17:57:56 -07:00
William Fu-Hinthorn 23bb5369b9 Studio cli command 2025-03-20 17:51:08 -07:00
Nuno CamposandGitHub 85b81371f7 Use incremental storage in memory checkpointer (#3960)
- This makes our checkpoint benchmarks more closely resemble the
behavior of our prod checkpointers
- Also found and fixed a bug w multiple subgraphs in same node
accidentally sharing checkpoints
2025-03-20 16:33:47 -07:00
Nuno Campos 54ab833b74 Lint 2025-03-20 16:26:52 -07:00
Nuno Campos 4ea936eaf4 Lint 2025-03-20 16:24:57 -07:00
Nuno Campos 9d81ec9ffd Lint 2025-03-20 16:22:24 -07:00
Nuno Campos 46b652a74c Use incremental storage in memory checkpointer
- This makes our checkpoint benchmarks more closely resemble the behavior of our prod checkpointers
- Also found and fixed a bug w multiple subgraphs in same node accidentally sharing checkpoints
2025-03-20 16:11:36 -07:00
Vadym BardaandGitHub 7013ca9a3f docs: use hosted logo (#3959) 2025-03-20 18:17:22 -04:00
William FHandGitHub 0c04aec664 Include enum in check for pydantic state (#3955) 2025-03-20 12:03:22 -07:00
Eugene YurtsevandGitHub 1650c8508e benchmark: Add compilation only (#3932)
Add compilation benchmark alone
2025-03-20 14:51:31 -04:00
Eugene YurtsevandGitHub e176b98fe7 Add llms-txt resources (#3935) 2025-03-20 14:44:21 -04:00
William Fu-Hinthorn eb1e1aa010 Include enum in check for pydantic state 2025-03-20 10:29:16 -07:00
Nuno CamposandGitHub 77c833e1e5 Use fast path for prepare_next_tasks on input (#3931)
- When there are no values in checkpoint no need to run through all the
PULL candidates
- When there are input writes save updated_channels to use on the next
call to prepare_next_tasks
2025-03-20 08:46:48 -07:00
Nuno Campos 0ac29434a7 Lint 2025-03-20 08:40:05 -07:00
Nuno Campos 43f5a17416 Lint 2025-03-20 08:24:51 -07:00
Nuno Campos 7d0857f263 Lint 2025-03-20 08:24:31 -07:00
Nuno Campos b82d70a66a Lint 2025-03-20 08:22:16 -07:00
Nuno CamposandGitHub 5fb037171d Small perf improvements (#3949)
- RunnableCallable: Skip signature checks for internal callables where
we know the signatures ahead of time
- PregelNode: Avoid redoing subgraphs serarch when copying it
- CompiledStateGraph: Avoid copying PregelNode when attaching writers
2025-03-20 08:19:02 -07:00
Nuno Campos d3bb2b9aa0 Lint 2025-03-20 08:18:17 -07:00
Nuno Campos ea765b4134 More small perf improvements
- RunnableCallable: Skip signature checks for internal callables where we know the signatures ahead of time
- PregelNode: Avoid redoing subgraphs serarch when copying it
- CompiledStateGraph: Avoid copying PregelNode when attaching writers
2025-03-20 08:11:28 -07:00
William FHandGitHub 66ff83dca9 Lock (#3947) 2025-03-20 08:04:53 -07:00
William FHandGitHub 254e398345 Merge branch 'main' into wfh/reloack 2025-03-20 08:04:38 -07:00
Vadym BardaandGitHub c7567ea219 docs: improve search (#3948) 2025-03-20 11:02:59 -04:00
William Fu-Hinthorn 8c0306c3f4 Lock 2025-03-20 07:59:30 -07:00
William FHandGitHub 992b05a196 langgraph-checkpoint-postgres 2.0.19 (#3945) 2025-03-20 07:25:41 -07:00
William Fu-Hinthorn 893a9646d3 langgraph-checkpoint-postgres 2.0.19 2025-03-20 07:25:14 -07:00
William FHandGitHub eaa37a2ce9 Increase pg->checkpoint minbound (#3944) 2025-03-20 07:24:43 -07:00
William Fu-Hinthorn daee8d88bb Increase pg->checkpoint minbound 2025-03-20 07:24:15 -07:00
Nuno Campos eaa18cc2dd Use fast path for prepare_next_tasks on input
- When there are no values in checkpoint no need to run through all the PULL candidates
- When there are input writes save updated_channels to use on the next call to prepare_next_tasks
2025-03-19 18:14:04 -07:00
Nuno CamposandGitHub b2d9a36308 langgraph: incorporate information about previously updated channels to identify which tasks to execute next (#3916)
Leverage information about which channels were updated in the previous
step to determine which tasks should be triggered. This can result in
significant speed up in prepare_next_tasks in some situations.
2025-03-19 16:22:12 -07:00
William FHandGitHub 03fc695d60 Add refcount test (#3910) 2025-03-19 14:33:43 -07:00
William Fu-Hinthorn 9994b09304 merge 2025-03-19 14:27:19 -07:00
William FHandGitHub 53f8558914 Release 0.3.18 (#3925)
Includes:
- Explicit unsetting of runnable context var
- Weakref for PregelExecutableTask

both to reduce the chance of keeping a reference to an internal object
and preventing garbage collection
2025-03-19 14:11:46 -07:00
William Fu-Hinthorn 4bfcd84cee Cleanup ref count check 2025-03-19 14:10:01 -07:00
David DuongandGitHub cd1d7be05f feat(sdk): add bulk_update_state in SDK (#3923) 2025-03-19 22:09:53 +01:00
Really HimandGitHub 939a426a2e DOCS: Update state-model.ipynb to use "AnyMessage" (#3926)
## Description
The documentation for working with Pydantic and graph State recommends
to use `AnyMessage` when working with LangChain types, but the code
example uses `BaseMessage`.
2025-03-19 21:08:14 +00:00
William Fu-Hinthorn 94c815f226 Release 0.3.18
Includes:
- Explicit unsetting of runnable context var
- Weakref for PregelExecutableTask

both to reduce the chance of keeping a reference to an internal object and preventing
garbage collection
2025-03-19 14:04:01 -07:00
Tat Dat Duong ef345aac5f Fix typo 2025-03-19 22:02:55 +01:00
William FHandGitHub e306258525 Reference to PregelExecutableTask (#3924) 2025-03-19 14:00:43 -07:00
William Fu-Hinthorn 05cd317486 Update snapshots more 2025-03-19 13:54:10 -07:00
Tat Dat Duong 6dfed31a5e Update parameters 2025-03-19 21:39:35 +01:00
ThaparandGitHub ee8374c4c0 docs: Update bad link in libs/cli README.md (#3919)
Fixed reference hyperlink
2025-03-19 16:27:37 -04:00
William Fu-Hinthorn 2066894b5f Update snapshots 2025-03-19 13:25:02 -07:00
William Fu-Hinthorn 6a2d20fd5b Reference to PregelExecutableTask 2025-03-19 13:19:02 -07:00
Eugene Yurtsev 5b8b9f1067 Update doc-string 2025-03-19 16:10:50 -04:00
Eugene Yurtsev c9cb8165d4 x 2025-03-19 16:08:25 -04:00
Tat Dat Duong 1f0348a5ca Fix docstring 2025-03-19 21:07:07 +01:00
Tat Dat Duong 442ef0788e Add graph_id back 2025-03-19 21:05:43 +01:00
Eugene Yurtsev 73f9ef0ef8 add type 2025-03-19 16:00:49 -04:00
Tat Dat Duong 1f7a380548 Fix typo 2025-03-19 20:54:15 +01:00
Eugene Yurtsev 8959f2aec5 lint 2025-03-19 15:54:07 -04:00
Eugene Yurtsev 0e7869eba4 Merge branch 'main' into ey/optimize_triggers 2025-03-19 15:52:29 -04:00
William FHandGitHub d3f8478054 Unset config context after function end (#3922) 2025-03-19 12:51:39 -07:00
Tat Dat Duong 0cb1893475 Update for JS as well 2025-03-19 20:46:22 +01:00
Tat Dat Duong e779c8e0b1 Merge into create 2025-03-19 20:42:48 +01:00
Eugene Yurtsev 18b82cb8e2 x 2025-03-19 15:29:50 -04:00
Tat Dat Duong 972ab1a935 Revert docstring for update_state 2025-03-19 20:22:05 +01:00
William Fu-Hinthorn 9cc2f37cca Unset config context after function end 2025-03-19 12:14:29 -07:00
Tat Dat Duong a2d7631f47 Fix in async client 2025-03-19 20:10:42 +01:00
Tat Dat Duong 1e767c0653 feat(sdk): add bulk_update_state in SDK 2025-03-19 20:05:08 +01:00
David DuongandGitHub d4c569cb7c feat(sdk-js): add bulkUpdateState method (#3878) 2025-03-19 19:25:37 +01:00
David DuongandGitHub a146df7f6a release(langgraph): 0.3.17 (#3918) 2025-03-19 19:10:01 +01:00
Tat Dat Duong c52cc03e4b release(langgraph): 0.3.17 2025-03-19 19:02:51 +01:00
Nuno CamposandGitHub f206cfad8f Store all triggers in task (#3912)
- These are used to update seen version
2025-03-19 09:47:06 -07:00
Nuno Campos d4c8b219c4 Update tests 2025-03-19 09:40:34 -07:00
Tat Dat Duong 00855999d2 Bump to 0.0.59 2025-03-19 17:02:19 +01:00
Tat Dat Duong 24bd0e1c1f Fix formatting 2025-03-19 16:59:53 +01:00
David DuongandGitHub 3b59055192 feat(pregel): add bulk update state method (#3737)
This method is useful for recreating a thread from a list of checkpoint
writes. A new method is needed to clone a checkpoint that has been
created from multiple writes (functional API, map-reduce)

Port of https://github.com/langchain-ai/langgraphjs/pull/969 and
https://github.com/langchain-ai/langgraphjs/pull/1007
2025-03-19 16:59:07 +01:00
Eugene Yurtsev 67a16bec53 more typos 2025-03-19 11:48:31 -04:00
Eugene Yurtsev f97802eed5 x 2025-03-19 11:44:42 -04:00
Eugene Yurtsev bccb796ccc x 2025-03-19 11:38:46 -04:00
Eugene Yurtsev 4068e9d135 x 2025-03-19 11:27:56 -04:00
Eugene Yurtsev a951334f7f qxqx 2025-03-19 11:18:00 -04:00
lc-arjunandGitHub 75a727877f feat: add docs for prompt engineering (#3846) 2025-03-19 10:51:54 -04:00
Tat Dat Duong 1cece3228c Fix indent bug 2025-03-19 15:37:43 +01:00
Tat Dat Duong 7ba48d75c9 Another merge issue 2025-03-19 15:05:22 +01:00
Tat Dat Duong 7fb0628957 Remove duplicated test 2025-03-19 14:44:07 +01:00
Tat Dat Duong 6edf29f043 Fix rebase artifacts 2025-03-19 14:39:59 +01:00
Tat Dat Duong c8a605cbc8 Update PregelProtocol 2025-03-19 14:29:11 +01:00
Tat Dat Duong fbec207446 Apply formatting 2025-03-19 14:29:09 +01:00
Tat Dat Duong 2223c82606 Update to match JS 2025-03-19 14:28:55 +01:00
Tat Dat Duong 06f2eef74c Fix bug with stale task_id 2025-03-19 14:22:25 +01:00
Tat Dat Duong 62aa66cd4b Add better docstrings 2025-03-19 14:22:25 +01:00
Tat Dat Duong 8ffe9634b7 Clearer breakdown 2025-03-19 14:22:25 +01:00
Tat Dat Duong 4b1d6d2aeb Rename to StateUpdate 2025-03-19 14:22:25 +01:00
Tat Dat Duong 199ab46429 Avoid using checkpointer.list in async 2025-03-19 14:22:25 +01:00
Tat Dat Duong c758954519 Use awith_checkpointer instead 2025-03-19 14:22:25 +01:00
Tat Dat Duong 5bfb3bb882 Avoid running with shallow checkpointer 2025-03-19 14:22:24 +01:00
Tat Dat Duong 68a5c3f4c7 Implement batch events for RemotePregel 2025-03-19 14:22:04 +01:00
Tat Dat Duong b7fb8e6afb Fix types 2025-03-19 14:22:04 +01:00
Tat Dat Duong c34c798763 Add tests 2025-03-19 14:22:04 +01:00
Tat Dat Duong 792cd805a7 Fix tests 2025-03-19 14:21:44 +01:00
Tat Dat Duong 764929afd9 Fix lint issues 2025-03-19 14:21:43 +01:00
Tat Dat Duong 1e751a2256 Fix typo 2025-03-19 14:21:43 +01:00
Tat Dat Duong e6726802f7 feat(pregel): add bulk update state method
This method is useful for recreating a thread from a list of checkpoint writes. A new method is needed to clone a checkpoint that has been created from multiple writes (functional API, map-reduce)

Port of https://github.com/langchain-ai/langgraphjs/pull/969
2025-03-19 14:21:43 +01:00
Nuno Campos a541376d10 Store all triggers in task
- These are used to update seen version
2025-03-18 21:17:26 -07:00
William Fu-Hinthorn f9780330a6 Add refcount test 2025-03-18 20:13:40 -07:00
Nuno CamposandGitHub 24f7d7c439 Add pydantic state benchmark case (#3872) 2025-03-18 18:10:50 -07:00
William FHandGitHub 9533d35a84 0.3.16 (#3908) 2025-03-18 17:37:16 -07:00
William FHandGitHub 48de4a7234 Fix reference cycle btwn PregelLoop and PregelRunner (#3907) 2025-03-18 17:37:01 -07:00
William Fu-Hinthorn 67ac35c5bc 0.3.16 2025-03-18 17:35:53 -07:00
William Fu-Hinthorn 12c7ddf3a3 Update kafka 2025-03-18 17:30:21 -07:00
William Fu-Hinthorn d6caa3b00a lint 2025-03-18 17:18:07 -07:00
William Fu-Hinthorn 3d48526c16 Methods are weak 2025-03-18 17:17:04 -07:00
Nuno Campos 2f3bd69bf5 Add pydantic benchmark 2025-03-18 16:12:34 -07:00
Nuno Campos f10a0c6f32 Simpler runner 2025-03-18 15:15:26 -07:00
2eba27b01f docs: fix typo in hil how to doc (#3850)
Current text in the doc is incorrect:
```
Use the search tool to ask the user where they are, then look up the weather there
```

The search tool is not the one to use. Instead should just tell the
model to ask the user.

In addition, an important step is missing and makes the code seem less
impactful:
```python
location = interrupt("Please provide your location:")
```

The question to ask the human is actually coming from the LLM, there is
no need to hardcode it:
```python
...
location = interrupt(ask.question)
```

Before merging, someone who validates this should push an update to cell
outputs. I cleared it out from my branch because it made too many
updates to the file and would make it harder to review.

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-18 18:00:49 -04:00
c0245a6ee9 docs: fix typo (#3854)
probably > properly

Co-authored-by: Vadym Barda <vadym@langchain.dev>
2025-03-18 18:00:39 -04:00
9e82d23252 docs: fix typos (#3823)
This pull request corrects a couple of typographical errors in the
documentation.

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
Co-authored-by: Vadym Barda <vadym@langchain.dev>
2025-03-18 17:58:47 -04:00
460c522902 docs: fix typo (#3856)
Added "a" to make paragraph correct.

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-18 17:58:30 -04:00
298a19b573 docs: add missing word 'in' in the docs related to passing runtime args to tools (#3900)
- Adds missing word 'in'
- Sentence should read -> "The core technique **in** the examples below
is"

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-18 21:56:33 +00:00
d2ec46b927 add ai-data-science-team to third party packages (#3719)
I'd like to add my AI Data Science Team to the LangGraph Prebuilt 3rd
Party Packages.

Repo: https://github.com/business-science/ai-data-science-team

Prebuilt Agent Guidelines:
https://langchain-ai.github.io/langgraph/prebuilt/

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-18 17:55:31 -04:00
Rafayet HabibandGitHub 22e4bf74fc Update importing HumanMessage in add-summary-conversation-history.ipynb (#3751) 2025-03-18 21:30:32 +00:00
khnealandGitHub 96a0536ec4 doc: remove Python max version limit from example pyproject.toml (#3843)
LangGraph officially supported Python 3.13 back in October 2024: 

https://changelog.langchain.com/announcements/langgraph-is-now-compatible-with-python-3-13

But why suggest restricting the version of Python at all? The app/agent
owner will be in control of the runtime version anyway, so don't add
unnecessary restrictions.

Note this example from Poetry:
https://python-poetry.org/docs/pyproject/#requires-python

I did not refactor pyproject.toml to the newer Poetry 2 / uv format, but
I can do that in a future PR if it will help... please don't let that
block approval+merging this PR.
2025-03-18 21:30:06 +00:00
8d33938173 Improve documentation string for SqliteSaver (#3857)
* Document that `check_same_thread` as an option when creating sqlite
connection.
* Document why it's OK to do that.

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-18 21:29:05 +00:00
William FHandGitHub 1c73b1e45a Schema coercer should never throw (#3871)
- pydantic will do that for us if needed
2025-03-18 14:17:08 -07:00
William FHandGitHub 465d5d648b libs: pregel: find_subgraph_pregel: Do not cache introspection (#3894)
When searching for subgraphs stop caching inspection for the graph drawing & subgraph inference.

Fixes #3842
2025-03-18 14:09:55 -07:00
William Fu-Hinthorn b751e8bcee _evaluate() forward ref 2025-03-18 14:07:23 -07:00
Nuno CamposandGitHub 8ec3982056 Enable larger cases of sequential bench graph (#3905)
- Now that we're a lot faster on this case, we can enable some larger
test cases
2025-03-18 14:05:27 -07:00
William FHandGitHub f17f264a7a Merge branch 'main' into fix_subgraph_tools 2025-03-18 13:49:30 -07:00
William Fu-Hinthorn 086443472f Drop cache 2025-03-18 13:46:41 -07:00
William Fu-Hinthorn 936e69404e Revert "libs: pregel: find_subgraph_pregel: Do not search via introspection"
This reverts commit 2458f2d2e0.
2025-03-18 13:43:08 -07:00
Tat Dat Duong b1a25abc73 Add command 2025-03-18 21:22:40 +01:00
Nuno Campos 0e70b8d94f Enable larger cases of sequential bench graph
- Now that we're a lot faster on this case, we can enable some larger test cases
2025-03-18 13:13:20 -07:00
Nuno CamposandGitHub e1aa1a4510 0.3.15 2025-03-18 13:11:37 -07:00
Hamza KyamanywaandGitHub ae7dbd1fa5 docs: correct the word "every" (#3902)
- PR fix the word "every" in the sentence "It will be called every time
the LLM is called"
2025-03-18 19:58:28 +00:00
Vadym BardaandGitHub 3ec95153ce ci: don't use real secrets in notebook runner (#3572) 2025-03-18 15:57:40 -04:00
Nuno CamposandGitHub 4836f8b18b Speed up prepare_single_task (#3893)
- sequential(2000) goes from 8.4s to 4.1s
- replace UUID(str).bytes with faster binascii.unhexlify, and do it only
once per step
- find only the first active trigger, instead of the full list
- use a dedicated function for checking active trigger
2025-03-18 10:15:17 -07:00
Vadym BardaandGitHub e7fbdeeb13 docs: fix formatting (#3901) 2025-03-18 13:00:36 -04:00
William Fu-Hinthorn ee650ab85f Only throw if in union 2025-03-18 09:58:07 -07:00
Nuno Campos 7a959f62cc Fix assertion 2025-03-18 09:54:20 -07:00
Yassin NouhandGitHub 82905297fd docs: Add Pydantic usage examples and runtime coercion documentation (#3588)
## Description
This PR enhances the state-model documentation by adding comprehensive
examples for advanced Pydantic usage in LangGraph. It addresses issue
#2745 regarding the need for better documentation of Pydantic schema
behavior.

### Changes
- Added new section on Advanced Pydantic Model Usage
- Added examples for serialization behavior with nested models
- Added section on runtime type coercion with examples
- Added documentation for proper message type handling (BaseMessage vs
AnyMessage)
- Updated Pydantic error URLs to latest version

### Related Issues
Closes #2745

### Testing
- All notebook cells have been executed and outputs verified
- Examples demonstrate proper usage patterns
- Error cases are properly documented

### Documentation
The changes are documentation-focused and include:
- New examples for complex Pydantic models
- Runtime coercion behavior examples
- Message type handling best practices

### Reviewers 
@eyurtsev
2025-03-18 09:47:17 -07:00
Nuno Campos 9b5549f759 Fix flaky assertion 2025-03-18 09:43:56 -07:00
Nuno Campos fa96c0ac76 One more 2025-03-18 09:34:52 -07:00
Nuno Campos 98b8ff904c Update test assertions for triggers 2025-03-18 09:30:29 -07:00
Nuno Campos 951131c8ec Lint 2025-03-18 09:15:43 -07:00
Nuno CamposandNuno Campos 8bcdba822e Reduce to 4.1s 2025-03-18 09:15:28 -07:00
Nuno CamposandNuno Campos 60fc49b448 Speed up prepare_single_task
- sequential(2000) goes from 8.4s to 4.7s
- replace UUID(str).bytes with simpler str.encode()
- find only the first active trigger, instead of the full list
- use a dedicated function for checking active trigger
2025-03-18 09:15:11 -07:00
Nuno CamposandGitHub 1d21b4ba08 Improve prepare_single_task trigger checks to linear complexity (#3891)
- Was O(n^2) due to individual channels created for every conditional
edge, including the default cond edge created for Command
- Now using a single channel per node for all conditional edge / command
triggers, reducing to linear complexity
- Improves run time on sequential(200) from 1.8s to 0.14s
2025-03-18 09:13:54 -07:00
Nuno CamposandGitHub 55ec0d3d2a Speed up task triggers check (#3890)
- Using a sentinel value is faster than raising-catching an exception
2025-03-18 09:10:38 -07:00
Nuno CamposandGitHub c7dd7be030 benchmarks: add sequential graph of a few hundred nodes (#3882)
Performance is poor due to state graph utilizing n^2 entries right now
to accommodate Command. Adding benchmark prior to updating
implementation.
2025-03-18 08:19:52 -07:00
Nuno Campos 47d38a3022 Replace get_catch w is_available 2025-03-18 08:19:22 -07:00
Nuno Campos 8e829f38af Smaller sizes until we merge the fixes 2025-03-18 08:05:25 -07:00
Nuno Campos 3f241d00a3 Fix bench 2025-03-18 06:45:41 -07:00
Hamza KyamanywaandGitHub f0abf582dd docs: make sentence relating to how to navigate between sub graphs clearer in the docs (#3896)
- fix typo / add missing word
- make sentence relating to how to navigate between sub graphs clearer
in the docs
2025-03-18 09:25:32 -04:00
blafab-hg 2458f2d2e0 libs: pregel: find_subgraph_pregel: Do not search via introspection
When searching for subgraphs do not attempt to search function non
locals for RunnableCallables as this captures unwanted reference to
surrounding variables.
2025-03-18 11:19:09 +01:00
Nuno CamposandGitHub 477a43dae0 Update pyproject.toml 2025-03-17 21:58:49 -07:00
Nuno CamposandGitHub fc8e6ec64f When using global resume value, ensure subgraphs consume it (#3889)
- Previously the global resume value was passed to subgraphs without
being consumed
- This would result in two parallel subgraph calls being able to use the
same resume value
- Note this behavior can't be implemented over the wire, that will be
fixed in future PR

Closes #3398
2025-03-17 21:26:33 -07:00
Nuno Campos d6a457ef1d Improve prepare_single_task trigger checks to linear complexity
- Was O(n^2) due to individual channels created for every conditional edge, including the default cond edge created for Command
- Now using a single channel per node for all conditional edge / command triggers, reducing to linear complexity
- Improves run time on sequential(200) from 1.8s to 0.14s
2025-03-17 21:26:26 -07:00
Nuno Campos ce1077da40 Speed up task triggers check
- Using a sentinel value is faster than raising-catching an exception
2025-03-17 21:04:05 -07:00
Nuno Campos 969958695a Add time when running directly 2025-03-17 20:59:10 -07:00
Nuno Campos dd16ae4ba5 When using global resume value, ensure subgraphs consume it
- Previously the global resume value was passed to subgraphs without being consumed
- This would result in two parallel subgraph calls being able to use the same resume value
- Note this behavior can't be implemented over the wire, that will be fixed in future PR
2025-03-17 20:31:54 -07:00
Nuno CamposandGitHub e24e141253 Fix concurrency issue in PregelScratchpad.consume_null_resume (#3888)
- Need to use a single operation to check if present and remove item
from list
- This doesn't fix the separate issue that parallel tasks claiming a
single interrupt value have somewhat undefined behavior (in the sense
that they will race to be the first to take it). That will be fixed in a
future PR

Closes #3875
2025-03-17 20:22:20 -07:00
Nuno Campos eae1faa656 Fix 2025-03-17 20:12:33 -07:00
Nuno Campos 1976d6584c Lint 2025-03-17 20:00:33 -07:00
Nuno Campos 54e18445fc Fix concurrency issue in PregelScratchpad.consume_null_resume
- Need to use a single operation to check if present and remove item from list
- This doesn't fix the separate issue that parallel tasks claiming a single interrupt value have somewhat undefined behavior (in the sense that they will race to be the first to take it). That will be fixed in a future PR
2025-03-17 19:53:03 -07:00
Nuno Campos 69dc29aaf9 0.3.13 2025-03-17 18:52:09 -07:00
Nuno CamposandGitHub aa5ff74845 Fix missing interrupts in stream (#3886)
- When multiple parallel tasks and/or subgraphs emit interrupts some
were missing from stream output
2025-03-17 18:51:42 -07:00
Nuno CamposandGitHub 6049aaa842 Enable xray for remote graphs (#3879) 2025-03-17 18:49:51 -07:00
Nuno Campos 576aa1ca02 Order 2025-03-17 18:41:36 -07:00
Nuno Campos e28e97d5e0 Lint 2025-03-17 18:39:13 -07:00
Nuno Campos 59e7c63c93 Lint 2025-03-17 18:38:37 -07:00
Nuno Campos be7dee1c3b Fix 2025-03-17 18:35:53 -07:00
Nuno Campos 0aafa04bac WIP Fix missing interrupts in stream
- When multiple parallel tasks and/or subgraphs emit interrupts some were missing from stream output
2025-03-17 18:31:15 -07:00
Nuno CamposandGitHub 2e1adaa867 0.3.12 2025-03-17 16:56:34 -07:00
Nuno CamposandGitHub 3f8b165592 Update state.py 2025-03-17 16:56:09 -07:00
William FHandGitHub 9ed0fa196c langgraph-checkpoint 2.0.21 (#3883) 2025-03-17 15:25:26 -07:00
William Fu-Hinthorn 0b9adc28c3 langgraph-checkpoint 2.0.21 2025-03-17 15:15:29 -07:00
William FHandGitHub 3b0255d1ef Check that migrations are idempotent (#3881) 2025-03-17 15:13:59 -07:00
Eugene Yurtsev 80c3ccba7b add benchmark 2025-03-17 16:45:02 -04:00
William FHandGitHub d4255a0645 Merge branch 'main' into wfh/idempotency_test_ 2025-03-17 13:27:12 -07:00
William FHandGitHub 80d61a2600 Make expires_at idempotent (#3880) 2025-03-17 13:26:20 -07:00
William Fu-Hinthorn 424f24720a Make expires_at idempotent 2025-03-17 13:25:22 -07:00
William Fu-Hinthorn 2a71180c1d Add tests for idempotency in migraionts 2025-03-17 12:43:21 -07:00
William Fu-Hinthorn 697f878e36 Make expires_at idempotent 2025-03-17 12:38:43 -07:00
William Fu-Hinthorn 987b9da4ab Merge branch 'main' into nc/16mar/schema-coercer-no-throw 2025-03-17 11:58:20 -07:00
Tat Dat Duong 4bff1df4b0 Bump to 0.0.58 2025-03-17 18:14:51 +01:00
Tat Dat Duong 5104e31e35 feat(sdk-js): add bulkUpdateState method 2025-03-17 18:14:33 +01:00
Tat Dat Duong 7ae4739630 Make options optional 2025-03-17 18:07:14 +01:00
Nuno Campos 5db1949ae3 Fix 2025-03-17 09:55:21 -07:00
Nuno Campos ddb29df667 Fix 2025-03-17 09:39:23 -07:00
Nuno Campos fa467573d7 Enable xray for remote graphs 2025-03-17 09:36:33 -07:00
Tat Dat Duong 1a728a93c6 feat(sdk-js): add bulkUpdateState method 2025-03-17 17:22:03 +01:00
alxdr3kandGitHub def69c59d2 docs: add missing namespace definition in persistence.md (#3859)
- fix typo
- add missing namespace definition for memory store
2025-03-17 09:38:15 -04:00
Nuno Campos aaa0cd6b51 Fix 2025-03-16 11:53:29 -07:00
Nuno Campos a9c831c11b Schema coercer should never throw
- pydantic will do that for us if needed
2025-03-16 11:46:34 -07:00
Nuno Campos bad4d17c34 Remove 'check size' ci job 2025-03-14 21:54:01 -07:00
Nuno Campos 55219b23d8 0.3.11 2025-03-14 16:18:50 -07:00
Nuno CamposandGitHub 8edbd39ad3 Add optional encryption of checkpointer payloads (#3852)
- no dependency on any particular encryption lib (there is no py stdlib
encryption lib)
- works with any modern checkpointer, ie. those which use dumps_typed
and loads_typed methods to serialize data
- uses the default msg pack serializer, but also works with any custom
serializer
- backwards compatible with unencrypted data in same storage (will just
be read unencrypted)
- providing easy constructor to use AES encryption through pycriptodome
library, one single line of code to add it in
- other encryption libraries or algorithms (even assymetric ones) can be
used by implementing the two-method CipherProtocol interface
- cipher name (eg. aes) is stored with encrypted payload for forwards
compatibility

```py
import sqlite3

from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.sqlite import SqliteSaver

# will read AES key from env var LANGGRAPH_AES_KEY
serde = EncryptedSerializer.from_pycryptodome_aes()
# works with any other checkpointer, including custom ones
checkpointer = SqliteSaver(sqlite3.connect('...'), serde=serde)
```
2025-03-14 16:14:29 -07:00
Nuno Campos 4b0fd834d8 Make it possible to implement a cipher that handles multiple protocols 2025-03-14 16:04:28 -07:00
Nuno Campos 0fd2748530 Accept custom serde implementations 2025-03-14 15:43:07 -07:00
Nuno Campos bc0a3419ed Lint 2025-03-14 15:38:22 -07:00
Nuno Campos 5cd47bac49 Add optional encryption of checkpointer payloads
- no dependency on any particular encryption lib (there is no py stdlib encryption lib)
- works with any modern checkpointer, ie. those which use dumps_typed and loads_typed methods to serialize data
- backwards compatible with unencrypted data in same storage (will just be read unencrypted)
- providing easy constructor to use AES encryption through pycriptodome library, one single line of code to add it in
- other encryption libraries or algorithms (even assymetric ones) can be used by implementing the two-method CipherProtocol interface
- cipher name (eg. aes) is stored with encrypted payload for forwards compatibility
2025-03-14 15:32:50 -07:00
William FHandGitHub 4c6d80a67f Add TTL Sweeper (#3849) 2025-03-14 14:29:40 -07:00
William Fu-Hinthorn 18ed044c27 Bump patch version 2025-03-14 14:03:30 -07:00
William Fu-Hinthorn 394a9fa85f Update schema 2025-03-14 13:48:23 -07:00
William Fu-Hinthorn 9741d9bdf0 Add tests for sweeper (sync) 2025-03-14 13:43:53 -07:00
William Fu-Hinthorn 06ca07432d Add sweeper 2025-03-13 18:55:16 -07:00
Nuno Campos e757a80001 0.3.10 2025-03-13 18:14:57 -07:00
William FHandGitHub a204444905 Update handling of updates/inputs passed in as pydantic models (#3839)
- remove usage of require_at_least_one_of, we shouldn't be enforcing
presence of keys in inputs/updates, an empty dict is a valid
input/update
- ensure that values that were explcitly set/assigned in pydantic model
are saved even if equal to default value
2025-03-13 18:14:17 -07:00
Nuno Campos 4cfdf8774a Update 2025-03-13 18:02:31 -07:00
Nuno Campos beb62fc053 Fix types 2025-03-13 17:39:43 -07:00
Nuno Campos 857f3e4a38 Update handling of updates/inputs passed in as pydantic models
- remove usage of require_at_least_one_of, we shouldn't be enforcing presence of keys in inputs/updates, an empty dict is a valid input/update
- ensure that values that were explcitly set/assigned in pydantic model are saved even if equal to default value
2025-03-13 17:31:38 -07:00
6342cd1665 prebuilt: fix typo and simplify functions of examples in chat_agent_executor.py. (#3827)
**Description:**
Fix typo and simplify functions of examples.

**Issue:**
N/A

**Dependencies:**
N/A

**Dependencies:**
N/A

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-13 15:32:25 -04:00
Eugene YurtsevandGitHub baedf91836 docs: fix regexp for base64 images for llms-full (#3834) 2025-03-13 15:32:09 -04:00
ArrayPDandGitHub 190b42850f docs: Enhance get_weather() in create-react-agent-memory.ipynb (#3825)
Since we are demonstrating thread-level memory not human-in-the-loop, a
string is more straightforward and reliable than AssertionError(), when
dealing with 'Unknown Location'.
2025-03-13 18:28:20 +00:00
Eugene YurtsevandGitHub 678b512aed ci: limit downloads from pypi stats to main branch (#3835)
At some point, we can run on a cron schedule
2025-03-13 14:24:55 -04:00
William FHandGitHub c85e246c32 0.3.9 (#3836) 2025-03-13 11:22:10 -07:00
William Fu-Hinthorn 98ebc45f31 0.3.9 2025-03-13 11:21:50 -07:00
David DuongandGitHub 5ca2f358f9 feat(sdk-js): automatically write ui messages (#3833) 2025-03-13 18:22:42 +01:00
William FHandGitHub 86169c1439 Coerce nested pydantic (#3806) 2025-03-13 10:17:53 -07:00
Tat Dat Duong 36d6eed468 feat(sdk-js): automatically write ui messages 2025-03-13 18:16:49 +01:00
William FHandGitHub ef50fed6fe StreamMode in Join [sdk] (#3584) 2025-03-13 10:05:45 -07:00
William Fu-Hinthorn c0abfc7df6 lint? 2025-03-13 09:55:41 -07:00
William Fu-Hinthorn 318889bc6c Merge branch 'main' into wfh/join_stream_mode 2025-03-13 09:46:23 -07:00
William Fu-Hinthorn b9e3fd5f3e Bump js 2025-03-13 09:45:47 -07:00
William Fu-Hinthorn 8729ebc40c Merge 2025-03-13 09:37:47 -07:00
William Fu-Hinthorn 919282fead Move files 2025-03-13 09:35:26 -07:00
David DuongandGitHub cff4784ff8 feat(docs): feedback on gen ui docs (#3831) 2025-03-13 17:31:56 +01:00
Tat Dat Duong 9921e5210a feat(docs): feedback on gen ui docs 2025-03-13 17:08:10 +01:00
Vadym BardaandGitHub 048eff9f11 prebuilt: release 0.1.3 (#3830) 2025-03-13 11:46:58 -04:00
ccurmeandGitHub 8a2765c8f2 prebuilt: update type annotation for tools (#3829) 2025-03-13 15:45:44 +00:00
William Fu-Hinthorn ffbcdd1ecc weakref 2025-03-13 08:04:57 -07:00
David DuongandGitHub 108a041fa7 feat(sdk-js): add docs for generative UI (#3774) 2025-03-13 15:17:54 +01:00
Tat Dat Duong 52e3c59f07 Replace with jpg 2025-03-13 15:09:24 +01:00
David DuongandGitHub 0e17988332 feat(sdk-js): add overridable ui namespacing (#3809) 2025-03-13 15:02:07 +01:00
Tat Dat Duong 5f0d05099d Bump to 0.0.54 2025-03-13 14:50:59 +01:00
Tat Dat Duong a1739d3184 Add a separate section for Frontend and Generative UI 2025-03-13 14:47:49 +01:00
Tat Dat Duong ab38cc2cc0 Further cleanup 2025-03-13 14:36:07 +01:00
Tat Dat Duong 647833dcaa Separate segments 2025-03-13 14:34:23 +01:00
Tat Dat Duong 1f03735b7d Add image 2025-03-13 14:29:35 +01:00
Vadym BardaandGitHub d980cca59b docs: fix link checking (#3826) 2025-03-13 13:28:35 +00:00
William FHandGitHub 944b93bf61 docs: more concise readme (#3815) 2025-03-13 09:03:18 -04:00
Vadym BardaandGitHub 8aa59d002a docs: add logo to readme (#3816) 2025-03-13 09:03:01 -04:00
Tat Dat Duong ed533b32a8 Add example for CSS 2025-03-13 13:47:20 +01:00
William Fu-Hinthorn d88f59eea4 Cache 2025-03-12 18:11:42 -07:00
Nuno CamposandGitHub fc5dde6c55 Rename env var (#3813) 2025-03-12 17:07:52 -07:00
Nuno Campos 4c902d21a3 Rename env var 2025-03-12 16:58:12 -07:00
Nuno CamposandGitHub 54804af06a Make default recursion_limit configurable by env var (#3812) 2025-03-12 16:54:59 -07:00
William Fu-Hinthorn 312f026e9c Add tests 2025-03-12 16:52:23 -07:00
Nuno Campos 6973b19cc7 Make default recursion_limit configurable by env var 2025-03-12 16:45:45 -07:00
Tat Dat Duong 45ed67856f feat(sdk-js): add overridable ui namespacing 2025-03-12 23:56:07 +01:00
Vadym BardaandGitHub 74b2dbe1ea docs: add reflection prebuilt (#3805) 2025-03-12 17:54:59 +00:00
Alexey BondarenkoandGitHub 7f803df586 Add state schemas to __all__ in chat_agent_executor.py (#3798) 2025-03-12 17:54:18 +00:00
Vadym BardaandGitHub a5b43c933a docs: update README (#3799) 2025-03-12 13:43:28 -04:00
Vadym BardaandGitHub aae2fb4b85 langgraph: release 0.3.8 (#3803) 2025-03-12 13:29:21 -04:00
Vadym BardaandGitHub c20a50875d langgraph: handle pydantic state updates better for fields w/ defaults (#3783) 2025-03-12 13:19:56 -04:00
MathieuandGitHub 779553f4aa docs: fix state type in Persistence documentation (#3801)
This PR fixes the type of `foo` in the `State` class in Persistence
documentation. The type was previously defined as `int`, but the code
uses it as a `str`.

Updated the type of `foo` in the documentation to `str` to match its
actual usage in the code.

No changes to the functionality or codebase, only a documentation fix.
2025-03-12 17:14:07 +00:00
ArrayPDandGitHub 537e69608e docs: Correct a typo in create-react-agent-memory.ipynb (#3800)
Fixed a typo in create-react-agent-memory.ipynb
2025-03-12 15:17:11 +00:00
Tat Dat Duong 78348d2d9f Improve docs 2025-03-11 21:06:02 +01:00
Tat Dat Duong 44af8d5257 Add a disclaimer 2025-03-11 19:24:11 +01:00
Tat Dat Duong ed69f60f24 Add missing links 2025-03-11 16:18:59 +01:00
Tat Dat Duong c55f1f12bf Update docs 2025-03-11 16:18:58 +01:00
Tat Dat Duong 5d76b1d624 Add docs 2025-03-11 16:18:58 +01:00
William Fu-Hinthorn b5a981d82d Review 2025-02-25 11:58:01 -08:00
William Fu-Hinthorn f679348327 StreamMode in Join [sdk] 2025-02-25 11:14:38 -08:00
111 changed files with 7181 additions and 2264 deletions
+10 -1
View File
@@ -102,7 +102,14 @@ jobs:
- name: Build llms-text
run: make llms-text
- name: Build site
run: make build-docs
run: |
# If this is main branch, then we want to download stats. we do this
# with the env variable DOWNLOAD_STATS=true
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
DOWNLOAD_STATS=true make build-docs
else
make build-docs
fi
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
OPENAI_API_KEY: sf-proj-1234567890 # fake placeholder, shouldn't actually be used
@@ -127,6 +134,7 @@ jobs:
--check-links-ignore "https://openai\.com/.*" \
--check-links-ignore "https://www\.uber\.com/.*" \
--check-links-ignore "https://pepy\.tech/.*" \
--check-links-ignore "docs/docs/static/wordmark_*" \
--check-links $(find site -name "index.html" | grep -v 'storm/index.html')
else
@@ -147,6 +155,7 @@ jobs:
--check-links-ignore "https://twitter.com/.*" \
--check-links-ignore "https://github\.com/.*" \
--check-links-ignore "/.*\.(ipynb|html)$" \
--check-links-ignore "docs/docs/static/wordmark_*" \
--check-links ${CHANGED_FILES} \
|| ([ $? = 5 ] && exit 0 || exit $?)
else
+7 -7
View File
@@ -57,13 +57,13 @@ jobs:
env:
# these won't actually be used because of the VCR cassettes
# but need to set them to avoid triggering getpass()
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }}
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
OPENAI_API_KEY: "very-secret-key"
ANTHROPIC_API_KEY: "very-secret-key"
TAVILY_API_KEY: "very-secret-key"
LANGSMITH_API_KEY: "very-secret-key"
NOMIC_API_KEY: "very-secret-key"
COHERE_API_KEY: "very-secret-key"
FIREWORKS_API_KEY: "very-secret-key"
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ github.event_name }}" = "schedule" ]; then
echo "Running all notebooks"
-29
View File
@@ -1,29 +0,0 @@
name: Check File Size
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
file-size-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
- name: Filter by size
# TODO: roll back the web voyager hack
run: |
large_added_files=$(find ${{ steps.changed-files.outputs.added_files }} -maxdepth 0 -size +1M | grep -v "web_voyager" || true)
if [ -n "$large_added_files" ]; then
echo "Large files added: $large_added_files"
echo "# Large files added:" >> $GITHUB_STEP_SUMMARY
echo "$large_added_files" >> $GITHUB_STEP_SUMMARY
exit 1
fi
+46 -298
View File
@@ -1,339 +1,87 @@
# 🦜🕸️LangGraph
<picture class="github-only">
<source media="(prefers-color-scheme: light)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_light.svg">
<img alt="LangGraph Logo" src="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg" width="80%">
</picture>
<div>
<br>
</div>
[![Version](https://img.shields.io/pypi/v/langgraph.svg)](https://pypi.org/project/langgraph/)
[![Downloads](https://static.pepy.tech/badge/langgraph/month)](https://pepy.tech/project/langgraph)
[![Open Issues](https://img.shields.io/github/issues-raw/langchain-ai/langgraph)](https://github.com/langchain-ai/langgraph/issues)
[![Docs](https://img.shields.io/badge/docs-latest-blue)](https://langchain-ai.github.io/langgraph/)
⚡ Building language agents as graphs ⚡
> [!NOTE]
> Looking for the JS version? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://langchain-ai.github.io/langgraphjs/).
## Overview
LangGraph — used by Replit, Uber, LinkedIn, GitLab and more — is a low-level orchestration framework for building controllable agents. While langchain provides integrations and composable components to streamline LLM application development, the LangGraph library enables agent orchestration — offering customizable architectures, long-term memory, and human-in-the-loop to reliably handle complex tasks.
[LangGraph](https://langchain-ai.github.io/langgraph/) is a library for building
stateful, multi-actor applications with LLMs, used to create agent and multi-agent
workflows. Check out an introductory tutorial [here](https://langchain-ai.github.io/langgraph/tutorials/introduction/).
LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
### Why use LangGraph?
LangGraph powers [production-grade agents](https://www.langchain.com/built-with-langgraph), trusted by Linkedin, Uber, Klarna, GitLab, and many more. LangGraph provides fine-grained control over both the flow and state of your agent applications. It implements a central [persistence layer](https://langchain-ai.github.io/langgraph/concepts/persistence/), enabling features that are common to most agent architectures:
- **Memory**: LangGraph persists arbitrary aspects of your application's state,
supporting memory of conversations and other updates within and across user
interactions;
- **Human-in-the-loop**: Because state is checkpointed, execution can be interrupted
and resumed, allowing for decisions, validation, and corrections at key stages via
human input.
Standardizing these components allows individuals and teams to focus on the behavior
of their agent, instead of its supporting infrastructure.
Through [LangGraph Platform](#langgraph-platform), LangGraph also provides tooling for
the development, deployment, debugging, and monitoring of your applications.
LangGraph integrates seamlessly with
[LangChain](https://python.langchain.com/docs/introduction/) and
[LangSmith](https://docs.smith.langchain.com/) (but does not require them).
To learn more about LangGraph, check out our first LangChain Academy
course, *Introduction to LangGraph*, available for free
[here](https://academy.langchain.com/courses/intro-to-langgraph).
### LangGraph Platform
[LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform) is infrastructure for deploying LangGraph agents. It is a commercial solution for deploying agentic applications to production, built on the open-source LangGraph framework. The LangGraph Platform consists of several components that work together to support the development, deployment, debugging, and monitoring of LangGraph applications: [LangGraph Server](https://langchain-ai.github.io/langgraph/concepts/langgraph_server) (APIs), [LangGraph SDKs](https://langchain-ai.github.io/langgraph/concepts/sdk) (clients for the APIs), [LangGraph CLI](https://langchain-ai.github.io/langgraph/concepts/langgraph_cli) (command line tool for building the server), and [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio) (UI/debugger).
See deployment options [here](https://langchain-ai.github.io/langgraph/concepts/deployment_options/)
(includes a free tier).
Here are some common issues that arise in complex deployments, which LangGraph Platform addresses:
- **Streaming support**: LangGraph Server provides [multiple streaming modes](https://langchain-ai.github.io/langgraph/concepts/streaming) optimized for various application needs
- **Background runs**: Runs agents asynchronously in the background
- **Support for long running agents**: Infrastructure that can handle long running processes
- **[Double texting](https://langchain-ai.github.io/langgraph/concepts/double_texting)**: Handle the case where you get two messages from the user before the agent can respond
- **Handle burstiness**: Task queue for ensuring requests are handled consistently without loss, even under heavy loads
## Installation
```shell
```bash
pip install -U langgraph
```
## Example
Let's build a tool-calling [ReAct-style](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#react-implementation) agent that uses a search tool!
```shell
pip install langchain-anthropic
```
```shell
export ANTHROPIC_API_KEY=sk-...
```
Optionally, we can set up [LangSmith](https://docs.smith.langchain.com/) for best-in-class observability.
```shell
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=lsv2_sk_...
```
The simplest way to create a tool-calling agent in LangGraph is to use `create_react_agent`:
<details open>
<summary>High-level implementation</summary>
To learn more about how to use LangGraph, check out [the docs](https://langchain-ai.github.io/langgraph/). We show a simple example below of how to create a ReAct agent.
```python
# This code depends on pip install langchain[anthropic]
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.memory import MemorySaver
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
# Define the tools for the agent to use
@tool
def search(query: str):
"""Call to surf the web."""
# This is a placeholder, but don't tell the LLM that...
if "sf" in query.lower() or "san francisco" in query.lower():
return "It's 60 degrees and foggy."
return "It's 90 degrees and sunny."
tools = [search]
model = ChatAnthropic(model="claude-3-5-sonnet-latest", temperature=0)
# Initialize memory to persist state between graph runs
checkpointer = MemorySaver()
app = create_react_agent(model, tools, checkpointer=checkpointer)
# Use the agent
final_state = app.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
config={"configurable": {"thread_id": 42}}
agent = create_react_agent("anthropic:claude-3-7-sonnet-latest", tools=[search])
agent.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]}
)
final_state["messages"][-1].content
```
```
"Based on the search results, I can tell you that the current weather in San Francisco is:\n\nTemperature: 60 degrees Fahrenheit\nConditions: Foggy\n\nSan Francisco is known for its microclimates and frequent fog, especially during the summer months. The temperature of 60°F (about 15.5°C) is quite typical for the city, which tends to have mild temperatures year-round. The fog, often referred to as "Karl the Fog" by locals, is a characteristic feature of San Francisco\'s weather, particularly in the mornings and evenings.\n\nIs there anything else you\'d like to know about the weather in San Francisco or any other location?"
```
Now when we pass the same <code>"thread_id"</code>, the conversation context is retained via the saved state (i.e. stored list of messages)
## Why use LangGraph?
```python
final_state = app.invoke(
{"messages": [{"role": "user", "content": "what about ny"}]},
config={"configurable": {"thread_id": 42}}
)
final_state["messages"][-1].content
```
LangGraph is built for developers who want to build powerful, adaptable AI agents. Developers choose LangGraph for:
```
"Based on the search results, I can tell you that the current weather in New York City is:\n\nTemperature: 90 degrees Fahrenheit (approximately 32.2 degrees Celsius)\nConditions: Sunny\n\nThis weather is quite different from what we just saw in San Francisco. New York is experiencing much warmer temperatures right now. Here are a few points to note:\n\n1. The temperature of 90°F is quite hot, typical of summer weather in New York City.\n2. The sunny conditions suggest clear skies, which is great for outdoor activities but also means it might feel even hotter due to direct sunlight.\n3. This kind of weather in New York often comes with high humidity, which can make it feel even warmer than the actual temperature suggests.\n\nIt's interesting to see the stark contrast between San Francisco's mild, foggy weather and New York's hot, sunny conditions. This difference illustrates how varied weather can be across different parts of the United States, even on the same day.\n\nIs there anything else you'd like to know about the weather in New York or any other location?"
```
</details>
- **Reliability and controllability.** Steer agent actions with moderation checks and human-in-the-loop approvals. LangGraph persists context for long-running workflows, keeping your agents on course.
- **Low-level and extensible.** Build custom agents with fully descriptive, low-level primitives free from rigid abstractions that limit customization. Design scalable multi-agent systems, with each agent serving a specific role tailored to your use case.
- **First-class streaming support.** With token-by-token streaming and streaming of intermediate steps, LangGraph gives users clear visibility into agent reasoning and actions as they unfold in real time.
> [!TIP]
> LangGraph is a **low-level** framework that allows you to implement any custom agent
architectures. Click on the low-level implementation below to see how to implement a
tool-calling agent from scratch.
LangGraph is trusted in production and powering agents for companies like:
<details>
<summary>Low-level implementation</summary>
- [Klarna](https://blog.langchain.dev/customers-klarna/): Customer support bot for 85 million active users
- [Elastic](https://www.elastic.co/blog/elastic-security-generative-ai-features): Security AI assistant for threat detection
- [Uber](https://dpe.org/sessions/ty-smith-adam-huda/this-year-in-ubers-ai-driven-developer-productivity-revolution/): Automated unit test generation
- [Replit](https://www.langchain.com/breakoutagents/replit): Code generation
- And many more ([see list here](https://www.langchain.com/built-with-langgraph))
```python
from typing import Literal
## LangGraphs ecosystem
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph, MessagesState
from langgraph.prebuilt import ToolNode
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
# Define the tools for the agent to use
@tool
def search(query: str):
"""Call to surf the web."""
# This is a placeholder, but don't tell the LLM that...
if "sf" in query.lower() or "san francisco" in query.lower():
return "It's 60 degrees and foggy."
return "It's 90 degrees and sunny."
## Pairing with LangGraph Platform
While LangGraph is our open-source agent orchestration framework, enterprises that need scalable agent deployment can benefit from [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/).
tools = [search]
LangGraph Platform can help engineering teams:
tool_node = ToolNode(tools)
- **Accelerate agent development**: Quickly create agent UXs with configurable templates and [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/) for visualizing and debugging agent interactions.
- **Deploy seamlessly**: We handle the complexity of deploying your agent. LangGraph Platform includes robust APIs for memory, threads, and cron jobs plus auto-scaling task queues & servers.
- **Centralize agent management & reusability**: Discover, reuse, and manage agents across the organization. Business users can also modify agents without coding.
model = ChatAnthropic(model="claude-3-5-sonnet-latest", temperature=0).bind_tools(tools)
## Additional resources
# Define the function that determines whether to continue or not
def should_continue(state: MessagesState) -> Literal["tools", END]:
messages = state['messages']
last_message = messages[-1]
# If the LLM makes a tool call, then we route to the "tools" node
if last_message.tool_calls:
return "tools"
# Otherwise, we stop (reply to the user)
return END
- [LangChain Academy](https://academy.langchain.com/courses/intro-to-langgraph): Learn the basics of LangGraph in our free, structured course.
- [Tutorials](https://langchain-ai.github.io/langgraph/tutorials/): Simple walkthroughs with guided examples on getting started with LangGraph.
- [Templates](https://langchain-ai.github.io/langgraph/concepts/template_applications/): Pre-built reference apps for common agentic workflows (e.g. ReAct agent, memory, retrieval etc.) that can be cloned and adapted.
- [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
- [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
- [Built with LangGraph](https://www.langchain.com/built-with-langgraph): Hear how industry leaders use LangGraph to ship powerful, production-ready AI applications.
## Acknowledgements
# Define the function that calls the model
def call_model(state: MessagesState):
messages = state['messages']
response = model.invoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
# Define a new graph
workflow = StateGraph(MessagesState)
# Define the two nodes we will cycle between
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
# Set the entrypoint as `agent`
# This means that this node is the first one called
workflow.add_edge(START, "agent")
# We now add a conditional edge
workflow.add_conditional_edges(
# First, we define the start node. We use `agent`.
# This means these are the edges taken after the `agent` node is called.
"agent",
# Next, we pass in the function that will determine which node is called next.
should_continue,
)
# We now add a normal edge from `tools` to `agent`.
# This means that after `tools` is called, `agent` node is called next.
workflow.add_edge("tools", 'agent')
# Initialize memory to persist state between graph runs
checkpointer = MemorySaver()
# Finally, we compile it!
# This compiles it into a LangChain Runnable,
# meaning you can use it as you would any other runnable.
# Note that we're (optionally) passing the memory when compiling the graph
app = workflow.compile(checkpointer=checkpointer)
# Use the agent
final_state = app.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
config={"configurable": {"thread_id": 42}}
)
final_state["messages"][-1].content
```
<b>Step-by-step Breakdown</b>:
<details>
<summary>Initialize the model and tools.</summary>
<ul>
<li>
We use <code>ChatAnthropic</code> as our LLM. <strong>NOTE:</strong> we need to make sure the model knows that it has these tools available to call. We can do this by converting the LangChain tools into the format for OpenAI tool calling using the <code>.bind_tools()</code> method.
</li>
<li>
We define the tools we want to use - a search tool in our case. It is really easy to create your own tools - see documentation here on how to do that <a href="https://python.langchain.com/docs/how_to/custom_tools/">here</a>.
</li>
</ul>
</details>
<details>
<summary>Initialize graph with state.</summary>
<ul>
<li>We initialize graph (<code>StateGraph</code>) by passing state schema (in our case <code>MessagesState</code>)</li>
<li><code>MessagesState</code> is a prebuilt state schema that has one attribute -- a list of LangChain <code>Message</code> objects, as well as logic for merging the updates from each node into the state.</li>
</ul>
</details>
<details>
<summary>Define graph nodes.</summary>
There are two main nodes we need:
<ul>
<li>The <code>agent</code> node: responsible for deciding what (if any) actions to take.</li>
<li>The <code>tools</code> node that invokes tools: if the agent decides to take an action, this node will then execute that action.</li>
</ul>
</details>
<details>
<summary>Define entry point and graph edges.</summary>
First, we need to set the entry point for graph execution - <code>agent</code> node.
Then we define one normal and one conditional edge. Conditional edge means that the destination depends on the contents of the graph's state (<code>MessagesState</code>). In our case, the destination is not known until the agent (LLM) decides.
<ul>
<li>Conditional edge: after the agent is called, we should either:
<ul>
<li>a. Run tools if the agent said to take an action, OR</li>
<li>b. Finish (respond to the user) if the agent did not ask to run tools</li>
</ul>
</li>
<li>Normal edge: after the tools are invoked, the graph should always return to the agent to decide what to do next</li>
</ul>
</details>
<details>
<summary>Compile the graph.</summary>
<ul>
<li>
When we compile the graph, we turn it into a LangChain
<a href="https://python.langchain.com/docs/concepts/runnables/">Runnable</a>,
which automatically enables calling <code>.invoke()</code>, <code>.stream()</code> and <code>.batch()</code>
with your inputs
</li>
<li>
We can also optionally pass checkpointer object for persisting state between graph runs, and enabling memory,
human-in-the-loop workflows, time travel and more. In our case we use <code>MemorySaver</code> -
a simple in-memory checkpointer
</li>
</ul>
</details>
<details>
<summary>Execute the graph.</summary>
<ol>
<li>LangGraph adds the input message to the internal state, then passes the state to the entrypoint node, <code>"agent"</code>.</li>
<li>The <code>"agent"</code> node executes, invoking the chat model.</li>
<li>The chat model returns an <code>AIMessage</code>. LangGraph adds this to the state.</li>
<li>Graph cycles the following steps until there are no more <code>tool_calls</code> on <code>AIMessage</code>:
<ul>
<li>If <code>AIMessage</code> has <code>tool_calls</code>, <code>"tools"</code> node executes</li>
<li>The <code>"agent"</code> node executes again and returns <code>AIMessage</code></li>
</ul>
</li>
<li>Execution progresses to the special <code>END</code> value and outputs the final state. And as a result, we get a list of all our chat messages as output.</li>
</ol>
</details>
</details>
## Documentation
* [Tutorials](https://langchain-ai.github.io/langgraph/tutorials/): Learn to build with LangGraph through guided examples.
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
* [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform): LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework.
## Resources
* [Built with LangGraph](https://www.langchain.com/built-with-langgraph): Hear how industry leaders use LangGraph to ship powerful, production-ready AI applications.
## Contributing
For more information on how to contribute, see [here](https://github.com/langchain-ai/langgraph/blob/main/CONTRIBUTING.md).
LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
+9 -1
View File
@@ -10,7 +10,15 @@ build-prebuilt:
# Use to create an update to date prebuilt page.
# Looks up download stats for each of the prebuilt packages and
# generates the final prebuilt page.
poetry run python -m _scripts.third_party_page.get_download_stats stats.yml
@if [ "$(DOWNLOAD_STATS)" = "true" ]; then \
set -x; \
poetry run python -m _scripts.third_party_page.get_download_stats stats.yml; \
set +x; \
else \
set -x; \
poetry run python -m _scripts.third_party_page.get_download_stats --fake stats.yml; \
set +x; \
fi
poetry run python -m _scripts.third_party_page.create_third_party_page stats.yml docs/prebuilt.md --language python
build-docs: build-typedoc build-prebuilt
+2
View File
@@ -14,6 +14,8 @@ To run the documentation server locally you can run:
make serve-docs
```
This will start the documentation server on [http://127.0.0.1:8000/langgraph/](http://127.0.0.1:8000/langgraph/).
## Execute notebooks
If you would like to automatically execute all of the notebooks, to mimic the "Run notebooks" GHA, you can run:
+1 -1
View File
@@ -186,7 +186,7 @@ def _on_page_markdown_with_config(
if remove_base64_images:
# Remove base64 encoded images from markdown
markdown = re.sub(r"!\[.*?\]\(data:image/+;base64,[^\)]+\)", "", markdown)
markdown = re.sub(r"!\[.*?\]\(data:image/[^;]+;base64,[^)]+\)", "", markdown)
return markdown
@@ -30,10 +30,23 @@ PACKAGES_FILE = HERE / "packages.yml"
PACKAGES = yaml.safe_load(PACKAGES_FILE.read_text())['packages']
def _get_weekly_downloads(packages: list[Package]) -> list[ResolvedPackage]:
def _get_weekly_downloads(packages: list[Package], fake: bool) -> list[ResolvedPackage]:
"""Retrieve the monthly download count for a list of packages from PyPIStats."""
resolved_packages: list[ResolvedPackage] = []
if fake:
# To avoid making network requests during testing, return fake download counts
for package in packages:
resolved_packages.append(
{
"name": package["name"],
"repo": package["repo"],
"weekly_downloads": -12345,
"description": package["description"],
}
)
return resolved_packages
for package in packages:
# First check if package exists on PyPI
pypi_url = f"https://pypi.org/pypi/{package['name']}/json"
@@ -88,13 +101,13 @@ def _get_weekly_downloads(packages: list[Package]) -> list[ResolvedPackage]:
def main(output_file: str) -> None:
def main(output_file: str, fake: bool) -> None:
"""Main function to generate package download information.
Args:
output_file: Path to the output YAML file.
"""
resolved_packages: list[ResolvedPackage] = _get_weekly_downloads(PACKAGES)
resolved_packages: list[ResolvedPackage] = _get_weekly_downloads(PACKAGES, fake)
if not output_file.endswith(".yml"):
raise ValueError("Output file must have a .yml extension")
@@ -115,6 +128,15 @@ if __name__ == "__main__":
"downloads.yml"
),
)
parser.add_argument(
"--fake",
default=False,
action="store_true",
help=(
"Generate fake download counts for testing purposes. "
"This option will not make any network requests."
),
)
args = parser.parse_args()
main(args.output_file)
main(args.output_file, args.fake)
@@ -30,3 +30,9 @@ packages:
- name: "langgraph-bigtool"
repo: "langchain-ai/langgraph-bigtool"
description: "Build LangGraph agents with large numbers of tools."
- name: "ai-data-science-team"
repo: "business-science/ai-data-science-team"
description: "An AI-powered data science team of agents to help you perform common data science tasks 10X faster."
- name: "langgraph-reflection"
repo: "langchain-ai/langgraph-reflection"
description: "LangGraph agent that runs a reflection step."
@@ -64,7 +64,7 @@ license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = ">=3.9.0,<3.13"
python = ">=3.9"
langgraph = "^0.2.0"
langchain-fireworks = "^0.1.3"
@@ -0,0 +1,312 @@
# How to implement Generative User Interfaces with LangGraph
!!! info "Prerequisites"
- [LangGraph Platform](../../concepts/langgraph_platform.md)
- [LangGraph Server](../../concepts/langgraph_server.md)
- [`useStream()` React Hook](./use_stream_react.md)
Generative user interfaces (Generative UI) allows agents to go beyond text and generate rich user interfaces. This enables creating more interactive and context-aware applications where the UI adapts based on the conversation flow and AI responses.
![Generative UI Sample](./img/generative_ui_sample.jpg)
LangGraph Platform supports colocating your React components with your graph code. This allows you to focus on building specific UI components for your graph while easily plugging into existing chat interfaces such as [Agent Chat](https://agentchat.vercel.app) and loading the code only when actually needed.
!!! warning "LangGraph.js only"
Currently only LangGraph.js supports Generative UI. Support for Python is coming soon.
## Tutorial
### 1. Define and configure UI components
First, create your first UI component. For each component you need to provide an unique identifier that will be used to reference the component in your graph code.
```tsx title="src/agent/ui.tsx"
const WeatherComponent = (props: { city: string }) => {
return <div>Weather for {props.city}</div>;
};
export default {
weather: WeatherComponent,
};
```
Next, define your UI components in your `langgraph.json` configuration:
```json
{
"node_version": "20",
"graphs": {
"agent": "./src/agent/index.ts:graph"
},
"ui": {
"agent": "./src/agent/ui.tsx"
}
}
```
The `ui` section points to the UI components that will be used by graphs. By default, we recommend using the same key as the graph name, but you can split out the components however you like, see [Customise the namespace of UI components](#customise-the-namespace-of-ui-components) for more details.
LangGraph Platform will automatically bundle your UI components code and styles and serve them as external assets that can be loaded by the `LoadExternalComponent` component. Some dependencies such as `react` and `react-dom` will be automatically excluded from the bundle.
CSS and Tailwind 4.x is also supported out of the box, so you can freely use Tailwind classes as well as `shadcn/ui` in your UI components.
=== "`src/agent/ui.tsx`"
```tsx
import "./styles.css";
const WeatherComponent = (props: { city: string }) => {
return <div className="bg-red-500">Weather for {props.city}</div>;
};
export default {
weather: WeatherComponent,
};
```
=== "`src/agent/styles.css`"
```css
@import "tailwindcss";
```
### 2. Send the UI components in your graph
Use the `typedUi` utility to emit UI elements from your agent nodes:
```typescript title="src/agent/index.ts"
import {
typedUi,
uiMessageReducer,
} from "@langchain/langgraph-sdk/react-ui/server";
import { ChatOpenAI } from "@langchain/openai";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import type ComponentMap from "./ui.js";
import {
Annotation,
MessagesAnnotation,
StateGraph,
type LangGraphRunnableConfig,
} from "@langchain/langgraph";
const AgentState = Annotation.Root({
...MessagesAnnotation.spec,
ui: Annotation({ reducer: uiMessageReducer, default: () => [] }),
});
export const graph = new StateGraph(AgentState)
.addNode("weather", async (state, config) => {
// Provide the type of the component map to ensure
// type safety of `ui.push()` calls as well as
// pushing the messages to the `ui` and sending a custom event as well.
const ui = typedUi<typeof ComponentMap>(config);
const weather = await new ChatOpenAI({ model: "gpt-4o-mini" })
.withStructuredOutput(z.object({ city: z.string() }))
.withConfig({ tags: ["langsmith:nostream"] })
.invoke(state.messages);
const response = {
id: uuidv4(),
type: "ai",
content: `Here's the weather for ${weather.city}`,
};
// Emit UI elements with associated AI message
ui.push({ name: "weather", props: weather }, { message: response });
return { messages: [response] };
})
.addEdge("__start__", "weather")
.compile();
```
### 3. Handle UI elements in your React application
On the client side, you can use `useStream()` and `LoadExternalComponent` to display the UI elements.
```tsx title="src/app/page.tsx"
"use client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
export default function Page() {
const { thread, values } = useStream({
apiUrl: "http://localhost:2024",
assistantId: "agent",
});
return (
<div>
{thread.messages.map((message) => (
<div key={message.id}>
{message.content}
{values.ui
?.filter((ui) => ui.metadata?.message_id === message.id)
.map((ui) => (
<LoadExternalComponent key={ui.id} stream={thread} message={ui} />
))}
</div>
))}
</div>
);
}
```
Behind the scenes, `LoadExternalComponent` will fetch the JS and CSS for the UI components from LangGraph Platform and render them in a shadow DOM, thus ensuring style isolation from the rest of your application.
## How-to guides
### Show loading UI when components are loading
You can provide a fallback UI to be rendered when the components are loading.
```tsx
<LoadExternalComponent
stream={thread}
message={ui}
fallback={<div>Loading...</div>}
/>
```
### Provide custom components on the client side
If you already have the components loaded in your client application, you can provide a map of such components to be rendered directly without fetching the UI code from LangGraph Platform.
```tsx
const clientComponents = {
weather: WeatherComponent,
};
<LoadExternalComponent
stream={thread}
message={ui}
components={clientComponents}
/>;
```
### Customise the namespace of UI components.
By default `LoadExternalComponent` will use the `assistantId` from `useStream()` hook to fetch the code for UI components. You can customise this by providing a `namespace` prop to the `LoadExternalComponent` component.
=== "`src/app/page.tsx`"
```tsx
<LoadExternalComponent
stream={thread}
message={ui}
namespace="custom-namespace"
/>
```
=== "`langgraph.json`"
```json
{
"ui": {
"custom-namespace": "./src/agent/ui.tsx"
}
}
```
### Access and interact with the thread state from the UI component
You can access the thread state inside the UI component by using the `useStreamContext` hook.
```tsx
import { useStreamContext } from "@langchain/langgraph-sdk/react-ui";
const WeatherComponent = (props: { city: string }) => {
const { thread, submit } = useStreamContext();
return (
<>
<div>Weather for {props.city}</div>
<button
onClick={() => {
const newMessage = {
type: "human",
content: `What's the weather in ${props.city}?`,
};
submit({ messages: [newMessage] });
}}
>
Retry
</button>
</>
);
};
```
### Pass additional context to the client components
You can pass additional context to the client components by providing a `meta` prop to the `LoadExternalComponent` component.
```tsx
<LoadExternalComponent stream={thread} message={ui} meta={{ userId: "123" }} />
```
Then, you can access the `meta` prop in the UI component by using the `useStreamContext` hook.
```tsx
import { useStreamContext } from "@langchain/langgraph-sdk/react-ui";
const WeatherComponent = (props: { city: string }) => {
const { meta } = useStreamContext<
{ city: string },
{ MetaType: { userId?: string } }
>();
return (
<div>
Weather for {props.city} (user: {meta?.userId})
</div>
);
};
```
### Streaming UI updates before the node execution is finished
You can stream UI updates before the node execution is finished by using the `onCustomEvent` callback of the `useStream()` hook.
```tsx
import { uiMessageReducer } from "@langchain/langgraph-sdk/react-ui";
const { thread, submit } = useStream({
apiUrl: "http://localhost:2024",
assistantId: "agent",
onCustomEvent: (event, options) => {
options.mutate((prev) => {
const ui = uiMessageReducer(prev.ui ?? [], event);
return { ...prev, ui };
});
},
});
```
### Remove UI messages from state
Similar to how messages can be removed from the state by appending a RemoveMessage you can remove an UI message from the state by calling `ui.delete` with the ID of the UI message.
```tsx
// pushed message
const message = ui.push({ name: "weather", props: { city: "London" } });
// remove said message
ui.delete(message.id);
// return new state to persist changes
return { ui: ui.items };
```
## Learn more
- [JS/TS SDK Reference](../reference/sdk/js_ts_sdk_ref.md)
@@ -63,7 +63,7 @@ Now, let's invoke our graph by interrupting before `ask_human` node:
"messages": [
{
"role": "user",
"content": "Use the search tool to ask the user where they are, then look up the weather there",
"content": "Ask the user where they are, then look up the weather there",
}
]
}
@@ -85,8 +85,7 @@ Now, let's invoke our graph by interrupting before `ask_human` node:
messages: [
{
role: "human",
content: "Use the search tool to ask the user where they are, then look up the weather there"
}
content: "Ask the user where they are, then look up the weather there" }
]
};
@@ -115,7 +114,7 @@ Now, let's invoke our graph by interrupting before `ask_human` node:
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Use the search tool to ask the user where they are, then look up the weather there\"}]},
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Ask the user where they are, then look up the weather there\"}]},
\"interrupt_before\": [\"ask_human\"],
\"stream_mode\": [
\"updates\"
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+128 -3
View File
@@ -1,6 +1,133 @@
# Prompt Engineering in LangGraph Studio
In LangGraph Studio you can iterate on the prompts used within your graph by utilizing the LangSmith Playground. To do so:
## Overview
A central aspect of agent development is prompt engineering. LangGraph Studio makes it easy to iterate on the prompts used within your graph directly within the UI.
## Setup
The first step is to define your [configuration](https://langchain-ai.github.io/langgraph/how-tos/configuration/) such that LangGraph Studio is aware of the prompts you want to iterate on and which nodes they are associated with.
### Reference
When defining your configuration, you can use special metadata keys to instruct LangGraph Studio how to handle different fields. Here's a reference for the available configuration options:
#### `langgraph_nodes`
- **Description**: Specifies which graph nodes a configuration field is associated with.
- **Value Type**: Array of strings, where each string is the name of a node in your graph.
- **Usage Context**: Include in the `json_schema_extra` dictionary for Pydantic models or the `metadata["json_schema_extra"]` dictionary for dataclasses.
- **Required**: No, but necessary if you want a field to be editable for specific nodes in the UI.
- **Example**:
```python
system_prompt: str = Field(
default="You are a helpful AI assistant.",
json_schema_extra={"langgraph_nodes": ["call_model", "other_node"]},
)
```
#### `langgraph_type`
- **Description**: Specifies the type of configuration field, which determines how it's handled in the UI.
- **Value Type**: String
- **Supported Values**:
- `"prompt"`: Indicates the field contains prompt text that should be treated specially in the UI.
- **Usage Context**: Include in the `json_schema_extra` dictionary for Pydantic models or the `metadata["json_schema_extra"]` dictionary for dataclasses.
- **Required**: No, but helpful for prompt fields to enable special handling.
- **Example**:
```python
system_prompt: str = Field(
default="You are a helpful AI assistant.",
json_schema_extra={
"langgraph_nodes": ["call_model"],
"langgraph_type": "prompt",
},
)
```
### Example
For example, if you have a node called `call_model` whose system prompt you want to iterate on, you can define a configuration like the following.
```python
## Using Pydantic
from pydantic import BaseModel, Field
from typing import Annotated, Literal
class Configuration(BaseModel):
"""The configuration for the agent."""
system_prompt: str = Field(
default="You are a helpful AI assistant.",
description="The system prompt to use for the agent's interactions. "
"This prompt sets the context and behavior for the agent.",
json_schema_extra={
"langgraph_nodes": ["call_model"],
"langgraph_type": "prompt",
},
)
model: Annotated[
Literal[
"anthropic/claude-3-7-sonnet-latest",
"anthropic/claude-3-5-haiku-latest",
"openai/o1",
"openai/gpt-4o-mini",
"openai/o1-mini",
"openai/o3-mini",
],
{"__template_metadata__": {"kind": "llm"}},
] = Field(
default="openai/gpt-4o-mini",
description="The name of the language model to use for the agent's main interactions. "
"Should be in the form: provider/model-name.",
json_schema_extra={"langgraph_nodes": ["call_model"]},
)
## Using Dataclasses
from dataclasses import dataclass, field
@dataclass(kw_only=True)
class Configuration:
"""The configuration for the agent."""
system_prompt: str = field(
default="You are a helpful AI assistant.",
metadata={
"description": "The system prompt to use for the agent's interactions. "
"This prompt sets the context and behavior for the agent.",
"json_schema_extra": {"langgraph_nodes": ["call_model"]},
},
)
model: Annotated[str, {"__template_metadata__": {"kind": "llm"}}] = field(
default="anthropic/claude-3-5-sonnet-20240620",
metadata={
"description": "The name of the language model to use for the agent's main interactions. "
"Should be in the form: provider/model-name.",
"json_schema_extra": {"langgraph_nodes": ["call_model"]},
},
)
```
## Iterating on prompts
### Node Configuration
With this set up, running your graph and viewing in LangGraph Studio will result in the graph rendering like such.
**Note the configuration icon in the top right corner of the `call_model` node**:
![Graph in Studio](../img/studio_graph_with_configuration.png){width=1200}
Clicking this icon will open a modal where you can edit the configuration for all of the fields associated with the `call_model` node. From here, you can save your changes and apply them to the graph. Note that these values reflect the currently active assistant, and saving will update the assistant with the new values.
![Configuration modal](../img/studio_node_configuration.png){width=1200}
### Playground
LangGraph Studio also supports prompt engineering through an integration with the LangSmith Playground. To do so:
1. Open an existing thread or create a new one.
2. Within the thread log, any nodes that have made an LLM call will have a "View LLM Runs" button. Clicking this will open a popover with the LLM runs for that node.
@@ -8,8 +135,6 @@ In LangGraph Studio you can iterate on the prompts used within your graph by uti
![Playground in Studio](../img/studio_playground.png){width=1200}
From here you can edit the prompt, test different model configurations and re-run just this LLM call without having to re-run the entire graph. When you are happy with your changes, you can copy the updated prompt back into your graph.
For more information on how to use the LangSmith Playground, see the [LangSmith Playground documentation](https://docs.smith.langchain.com/prompt_engineering/how_to_guides#playground).
+1 -1
View File
@@ -14,7 +14,7 @@ As a result, there are many different types of [agent architectures](https://blo
## Router
A router allows an LLM to select a single step from a specified set of options. This is an agent architecture that exhibits a relatively limited level of control because the LLM usually focuses on making a single decision and produces a specific output from limited set of pre-defined options. Routers typically employ a few different concepts to achieve this.
A router allows an LLM to select a single step from a specified set of options. This is an agent architecture that exhibits a relatively limited level of control because the LLM usually focuses on making a single decision and produces a specific output from a limited set of pre-defined options. Routers typically employ a few different concepts to achieve this.
### Structured Output
+5
View File
@@ -1,3 +1,8 @@
---
search:
boost: 2
---
# LangGraph Platform
## Overview
+4 -4
View File
@@ -89,7 +89,7 @@ def transfer_to_bob(state):
)
```
This is a special case of updating the graph state from tools where in addition the state update, the control flow is included as well.
This is a special case of updating the graph state from tools where, in addition to the state update, the control flow is included as well.
!!! important
@@ -235,7 +235,7 @@ supervisor = create_react_agent(model, tools)
### Hierarchical
As you add more agents to your system, it might become too hard for the supervisor to manage all of them. The supervisor might start making poor decisions about which agent to call next, the context might become too complex for a single supervisor to keep track of. In other words, you end up with the same problems that motivated the multi-agent architecture in the first place.
As you add more agents to your system, it might become too hard for the supervisor to manage all of them. The supervisor might start making poor decisions about which agent to call next, or the context might become too complex for a single supervisor to keep track of. In other words, you end up with the same problems that motivated the multi-agent architecture in the first place.
To address this, you can design your system _hierarchically_. For example, you can create separate, specialized teams of agents managed by individual supervisors, and a top-level supervisor to manage the teams.
@@ -339,9 +339,9 @@ builder.add_edge("agent_1", "agent_2")
## Communication between agents
The most important thing when building multi-agent systems is figuring out how the agents communicate. There are few different considerations:
The most important thing when building multi-agent systems is figuring out how the agents communicate. There are a few different considerations:
- Do agents communicate via [**via graph state or via tool calls**](#graph-state-vs-tool-calls)?
- Do agents communicate [**via graph state or via tool calls**](#graph-state-vs-tool-calls)?
- What if two agents have [**different state schemas**](#different-state-schemas)?
- How to communicate over a [**shared message list**](#shared-message-list)?
+5 -2
View File
@@ -32,7 +32,7 @@ from typing_extensions import TypedDict
from operator import add
class State(TypedDict):
foo: int
foo: str
bar: Annotated[list[str], add]
def node_a(state: State):
@@ -232,7 +232,7 @@ from langgraph.store.memory import InMemoryStore
in_memory_store = InMemoryStore()
```
Memories are namespaced by a `tuple`, which in this specific example will be `(<user_id>, "memories")`. The namespace can be any length and represent anything, does not have be user specific.
Memories are namespaced by a `tuple`, which in this specific example will be `(<user_id>, "memories")`. The namespace can be any length and represent anything, does not have to be user specific.
```python
user_id = "1"
@@ -387,6 +387,9 @@ We can access the memories and use them in our model call.
def call_model(state: MessagesState, config: RunnableConfig, *, store: BaseStore):
# Get the user id from the config
user_id = config["configurable"]["user_id"]
# Namespace the memory
namespace = (user_id, "memories")
# Search based on the most recent message
memories = store.search(
@@ -1,3 +1,8 @@
---
search:
exclude: true
---
# Human-in-the-loop
!!! note "Use the `interrupt` function instead."
+1 -1
View File
@@ -33,7 +33,7 @@
" )\n",
"```\n",
"\n",
"If you are using [subgraphs](#subgraphs), you might want to navigate from a node a subgraph to a different subgraph (i.e. a different node in the parent graph). To do so, you can specify `graph=Command.PARENT` in `Command`:\n",
"If you are using [subgraphs](#subgraphs), you might want to navigate from a node within a subgraph to a different subgraph (i.e. a different node in the parent graph). To do so, you can specify `graph=Command.PARENT` in `Command`:\n",
"\n",
"```python\n",
"def my_node(state: State) -> Command[Literal[\"my_other_node\"]]:\n",
@@ -122,20 +122,18 @@
"\n",
"# For this tutorial we will use custom tool that returns pre-defined values for weather in two cities (NYC & SF)\n",
"\n",
"from typing import Literal\n",
"\n",
"from langchain_core.tools import tool\n",
"\n",
"\n",
"@tool\n",
"def get_weather(city: Literal[\"nyc\", \"sf\"]):\n",
"def get_weather(location: str) -> str:\n",
" \"\"\"Use this to get weather information.\"\"\"\n",
" if city == \"nyc\":\n",
" if any([city in location.lower() for city in [\"nyc\", \"new york city\"]]):\n",
" return \"It might be cloudy in nyc\"\n",
" elif city == \"sf\":\n",
" elif any([city in location.lower() for city in [\"sf\", \"san francisco\"]]):\n",
" return \"It's always sunny in sf\"\n",
" else:\n",
" raise AssertionError(\"Unknown city\")\n",
" return f\"I am not sure what the weather is in {location}\"\n",
"\n",
"\n",
"tools = [get_weather]\n",
@@ -220,7 +218,7 @@
"id": "838a043f-90ad-4e69-9d1d-6e22db2c346c",
"metadata": {},
"source": [
"Notice that when we pass the same the same thread ID, the chat history is preserved"
"Notice that when we pass the same thread ID, the chat history is preserved."
]
},
{
@@ -397,7 +397,8 @@
"# We define a fake node to ask the human\n",
"def ask_human(state):\n",
" tool_call_id = state[\"messages\"][-1].tool_calls[0][\"id\"]\n",
" location = interrupt(\"Please provide your location:\")\n",
" ask = AskHuman.model_validate(state[\"messages\"][-1].tool_calls[0][\"args\"])\n",
" location = interrupt(ask.question)\n",
" tool_message = [{\"tool_call_id\": tool_call_id, \"type\": \"tool\", \"content\": location}]\n",
" return {\"messages\": tool_message}\n",
"\n",
@@ -491,7 +492,7 @@
" \"messages\": [\n",
" (\n",
" \"user\",\n",
" \"Use the search tool to ask the user where they are, then look up the weather there\",\n",
" \"Ask the user where they are, then look up the weather there\",\n",
" )\n",
" ]\n",
" },\n",
+7 -1
View File
@@ -198,7 +198,6 @@ Learn how to set up your app for deployment to LangGraph Platform:
- [How to test locally](../cloud/deployment/test_locally.md)
- [How to rebuild graph at runtime](../cloud/deployment/graph_rebuild.md)
- [How to use LangGraph Platform to deploy CrewAI, AutoGen, and other frameworks](autogen-langgraph-platform.ipynb)
- [How to integrate LangGraph into your React application](../cloud/how-tos/use_stream_react.md)
### Deployment
@@ -257,6 +256,13 @@ Streaming the results of your LLM application is vital for ensuring a good user
- [How to stream in debug mode](../cloud/how-tos/stream_debug.md)
- [How to stream multiple modes](../cloud/how-tos/stream_multiple.md)
### Frontend and Generative UI
With LangGraph Platform you can integrate LangGraph agents into your React applications and colocate UI components with your agent code.
- [How to integrate LangGraph into your React application](../cloud/how-tos/use_stream_react.md)
- [How to implement Generative User Interfaces with LangGraph](../cloud/how-tos/generative_ui_react.md)
### Human-in-the-loop
When designing complex graphs, relying entirely on the LLM for decision-making can be risky, particularly when it involves tools that interact with files, APIs, or databases. These interactions may lead to unintended data access or modifications, depending on the use case. To mitigate these risks, LangGraph allows you to integrate human-in-the-loop behavior, ensuring your LLM applications operate as intended without undesirable outcomes.
@@ -99,7 +99,7 @@
"from typing import Literal\n",
"\n",
"from langchain_anthropic import ChatAnthropic\n",
"from langchain_core.messages import SystemMessage, RemoveMessage\n",
"from langchain_core.messages import SystemMessage, RemoveMessage, HumanMessage\n",
"from langgraph.checkpoint.memory import MemorySaver\n",
"from langgraph.graph import MessagesState, StateGraph, START, END\n",
"\n",
@@ -7,7 +7,7 @@
"source": [
"# How to manage conversation history\n",
"\n",
"One of the most common use cases for persistence is to use it to keep track of conversation history. This is great - it makes it easy to continue conversations. As conversations get longer and longer, however, this conversation history can build up and take up more and more of the context window. This can often be undesirable as it leads to more expensive and longer calls to the LLM, and potentially ones that error. In order to prevent this from happening, you need to probably manage the conversation history.\n",
"One of the most common use cases for persistence is to use it to keep track of conversation history. This is great - it makes it easy to continue conversations. As conversations get longer and longer, however, this conversation history can build up and take up more and more of the context window. This can often be undesirable as it leads to more expensive and longer calls to the LLM, and potentially ones that error. In order to prevent this from happening, you need to properly manage the conversation history.\n",
"\n",
"Note: this guide focuses on how to do this in LangGraph, where you can fully customize how this is done. If you want a more off-the-shelf solution, you can look into functionality provided in LangChain:\n",
"\n",
@@ -38,7 +38,7 @@
" </p>\n",
"</div> \n",
"\n",
"The core technique the examples below is to **annotate** a parameter as \"injected\", meaning it will be injected by your program and should not be seen or populated by the LLM. Let the following codesnippet serve as a tl;dr:\n",
"The core technique in the examples below is to **annotate** a parameter as \"injected\", meaning it will be injected by your program and should not be seen or populated by the LLM. Let the following codesnippet serve as a tl;dr:\n",
"\n",
"```python\n",
"from typing import Annotated\n",
@@ -65,7 +65,7 @@
"\n",
"**Pros and Cons**\n",
"\n",
"The benefit to this format is that you only need one LLM, and can save money and latency because of this. The downside to this option is that it isn't guaranteed that the single LLM will call the correct tool when you want it to. We can help the LLM by setting `tool_choice` to `any` when we use `bind_tools` which forces the LLM to select at least one tool at every turn, but this is far from a fool proof strategy. In addition, another downside is that the agent might call *multiple* tools, so we need to check for this explicitly in our routing function (or if we are using OpenAI we an set `parallell_tool_calling=False` to ensure only one tool is called at a time).\n",
"The benefit to this format is that you only need one LLM, and can save money and latency because of this. The downside to this option is that it isn't guaranteed that the single LLM will call the correct tool when you want it to. We can help the LLM by setting `tool_choice` to `any` when we use `bind_tools` which forces the LLM to select at least one tool at every turn, but this is far from a foolproof strategy. In addition, another downside is that the agent might call *multiple* tools, so we need to check for this explicitly in our routing function (or if we are using OpenAI we can set `parallell_tool_calling=False` to ensure only one tool is called at a time).\n",
"\n",
"**Option 2**\n",
"\n",
+229
View File
@@ -266,6 +266,235 @@
" print(\"An exception was raised because bad_node sets `a` to an integer.\")\n",
" print(e)"
]
},
{
"cell_type": "markdown",
"id": "2270bc3c",
"metadata": {},
"source": [
"## Multiple Nodes\n",
"\n",
"Run-time validation will also work in a multi-node graph. In the example below `bad_node` updates `a` to an integer. \n",
"\n",
"Because run-time validation occurs on **inputs**, the validation error will occur when `ok_node` is called (not when `bad_node` returns an update to the state which is inconsistent with the schema)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d832cdcc",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, START, END\n",
"from typing_extensions import TypedDict\n",
"\n",
"from pydantic import BaseModel\n",
"\n",
"\n",
"# The overall state of the graph (this is the public state shared across nodes)\n",
"class OverallState(BaseModel):\n",
" a: str\n",
"\n",
"\n",
"def bad_node(state: OverallState):\n",
" return {\n",
" \"a\": 123 # Invalid\n",
" }\n",
"\n",
"\n",
"def ok_node(state: OverallState):\n",
" return {\"a\": \"goodbye\"}\n",
"\n",
"\n",
"# Build the state graph\n",
"builder = StateGraph(OverallState)\n",
"builder.add_node(bad_node)\n",
"builder.add_node(ok_node)\n",
"builder.add_edge(START, \"bad_node\")\n",
"builder.add_edge(\"bad_node\", \"ok_node\")\n",
"builder.add_edge(\"ok_node\", END)\n",
"graph = builder.compile()\n",
"\n",
"# Test the graph with a valid input\n",
"try:\n",
" graph.invoke({\"a\": \"hello\"})\n",
"except Exception as e:\n",
" print(\"An exception was raised because bad_node sets `a` to an integer.\")\n",
" print(e)"
]
},
{
"cell_type": "markdown",
"id": "456b1f77",
"metadata": {},
"source": [
"## Advanced Pydantic Model Usage\n",
"\n",
"This section covers more advanced topics when using Pydantic models with LangGraph.\n",
"\n",
"### Serialization Behavior\n",
"\n",
"When using Pydantic models as state schemas, it's important to understand how serialization works, especially when:\n",
"- Passing Pydantic objects as inputs\n",
"- Receiving outputs from the graph\n",
"- Working with nested Pydantic models\n",
"\n",
"Let's see these behaviors in action:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0e919cdc",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, START, END\n",
"from pydantic import BaseModel\n",
"\n",
"\n",
"class NestedModel(BaseModel):\n",
" value: str\n",
"\n",
"\n",
"class ComplexState(BaseModel):\n",
" text: str\n",
" count: int\n",
" nested: NestedModel\n",
"\n",
"\n",
"def process_node(state: ComplexState):\n",
" # Node receives a validated Pydantic object\n",
" print(f\"Input state type: {type(state)}\")\n",
" print(f\"Nested type: {type(state.nested)}\")\n",
"\n",
" # Return a dictionary update\n",
" return {\"text\": state.text + \" processed\", \"count\": state.count + 1}\n",
"\n",
"\n",
"# Build the graph\n",
"builder = StateGraph(ComplexState)\n",
"builder.add_node(\"process\", process_node)\n",
"builder.add_edge(START, \"process\")\n",
"builder.add_edge(\"process\", END)\n",
"graph = builder.compile()\n",
"\n",
"# Create a Pydantic instance for input\n",
"input_state = ComplexState(text=\"hello\", count=0, nested=NestedModel(value=\"test\"))\n",
"print(f\"Input object type: {type(input_state)}\")\n",
"\n",
"# Invoke graph with a Pydantic instance\n",
"result = graph.invoke(input_state)\n",
"print(f\"Output type: {type(result)}\")\n",
"print(f\"Output content: {result}\")\n",
"\n",
"# Convert back to Pydantic model if needed\n",
"output_model = ComplexState(**result)\n",
"print(f\"Converted back to Pydantic: {type(output_model)}\")"
]
},
{
"cell_type": "markdown",
"id": "f13f28ce",
"metadata": {},
"source": [
"### Runtime Type Coercion\n",
"\n",
"Pydantic performs runtime type coercion for certain data types. This can be helpful but also lead to unexpected behavior if you're not aware of it."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "faf59316",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, START, END\n",
"from pydantic import BaseModel\n",
"\n",
"\n",
"class CoercionExample(BaseModel):\n",
" # Pydantic will coerce string numbers to integers\n",
" number: int\n",
" # Pydantic will parse string booleans to bool\n",
" flag: bool\n",
"\n",
"\n",
"def inspect_node(state: CoercionExample):\n",
" print(f\"number: {state.number} (type: {type(state.number)})\")\n",
" print(f\"flag: {state.flag} (type: {type(state.flag)})\")\n",
" return {}\n",
"\n",
"\n",
"builder = StateGraph(CoercionExample)\n",
"builder.add_node(\"inspect\", inspect_node)\n",
"builder.add_edge(START, \"inspect\")\n",
"builder.add_edge(\"inspect\", END)\n",
"graph = builder.compile()\n",
"\n",
"# Demonstrate coercion with string inputs that will be converted\n",
"result = graph.invoke({\"number\": \"42\", \"flag\": \"true\"})\n",
"\n",
"# This would fail with a validation error\n",
"try:\n",
" graph.invoke({\"number\": \"not-a-number\", \"flag\": \"true\"})\n",
"except Exception as e:\n",
" print(f\"\\nExpected validation error: {e}\")"
]
},
{
"cell_type": "markdown",
"id": "2844475b",
"metadata": {},
"source": [
"### Working with Message Models\n",
"\n",
"When working with LangChain message types in your state schema, there are important considerations for serialization. You should use `AnyMessage` (rather than `BaseMessage`) for proper serialization/deserialization when using message objects over the wire:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "bd0734b0",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, START, END\n",
"from pydantic import BaseModel\n",
"from langchain_core.messages import HumanMessage, AIMessage, AnyMessage\n",
"from typing import List\n",
"\n",
"\n",
"class ChatState(BaseModel):\n",
" messages: List[AnyMessage]\n",
" context: str\n",
"\n",
"\n",
"def add_message(state: ChatState):\n",
" return {\"messages\": state.messages + [AIMessage(content=\"Hello there!\")]}\n",
"\n",
"\n",
"builder = StateGraph(ChatState)\n",
"builder.add_node(\"add_message\", add_message)\n",
"builder.add_edge(START, \"add_message\")\n",
"builder.add_edge(\"add_message\", END)\n",
"graph = builder.compile()\n",
"\n",
"# Create input with a message\n",
"initial_state = ChatState(\n",
" messages=[HumanMessage(content=\"Hi\")], context=\"Customer support chat\"\n",
")\n",
"\n",
"result = graph.invoke(initial_state)\n",
"print(f\"Output: {result}\")\n",
"\n",
"# Convert back to Pydantic model to see message types\n",
"output_model = ChatState(**result)\n",
"for i, msg in enumerate(output_model.messages):\n",
" print(f\"Message {i}: {type(msg).__name__} - {msg.content}\")"
]
}
],
"metadata": {
@@ -210,7 +210,7 @@
"id": "cbb06aea-6654-4245-91f8-af6e8f2b5377",
"metadata": {},
"source": [
"Let's now add personalization: we'll respond differently to the user based on the state values AFTER the state has been updated from the tool. To achieve this, let's define a function that will dynamically construct the system prompt based on the graph state. It will be called ever time the LLM is called and the function output will be passed to the LLM:"
"Let's now add personalization: we'll respond differently to the user based on the state values AFTER the state has been updated from the tool. To achieve this, let's define a function that will dynamically construct the system prompt based on the graph state. It will be called every time the LLM is called and the function output will be passed to the LLM:"
]
},
{
+22
View File
@@ -3,4 +3,26 @@ hide_comments: true
title: Home
---
<script>
// This script only runs in MkDocs, not on GitHub
var hideGitHubVersion = function() {
document.querySelectorAll('.github-only').forEach(el => el.style.display = 'none');
};
// Handle both initial load and subsequent navigation
document.addEventListener('DOMContentLoaded', hideGitHubVersion);
document$.subscribe(hideGitHubVersion);
</script>
<p class="mkdocs-only">
<img class="logo-light" src="static/wordmark_dark.svg" alt="LangGraph Logo" width="80%">
<img class="logo-dark" src="static/wordmark_light.svg" alt="LangGraph Logo" width="80%">
</p>
<style>
.md-content h1 {
display: none;
}
</style>
{!../README.md!}
+36
View File
@@ -0,0 +1,36 @@
# LLMs-txt for LangGraph
## Overview
LangGraph provides documentation files in the [`llms.txt`](https://llmstxt.org/) format, specifically `llms.txt` and `llms-full.txt`. These files allow large language models (LLMs) and agents to access programming documentation and APIs, particularly useful within integrated development environments (IDEs).
| Language Version | llms.txt | llms-full.txt |
|------------------|------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
| LangGraph Python | [https://langchain-ai.github.io/langgraph/llms.txt](https://langchain-ai.github.io/langgraph/llms.txt) | [https://langchain-ai.github.io/langgraph/llms-full.txt](https://langchain-ai.github.io/langgraph/llms-full.txt) |
| LangGraph JS | [https://langchain-ai.github.io/langgraphjs/llms.txt](https://langchain-ai.github.io/langgraphjs/llms.txt) | [https://langchain-ai.github.io/langgraphjs/llms-full.txt](https://langchain-ai.github.io/langgraphjs/llms-full.txt) |
## Differences Between `llms.txt` and `llms-full.txt`
- **`llms.txt`** is an index file containing links with brief descriptions of the content. An LLM or agent must follow these links to access detailed information.
- **`llms-full.txt`** includes all the detailed content directly in a single file, eliminating the need for additional navigation.
A key consideration when using `llms-full.txt` is its size. For extensive documentation, this file may become too large to fit into an LLM's context window.
## Using `llms.txt` via an MCP Server
As of March 9, 2025, IDEs [do not yet have robust native support for `llms.txt`](https://x.com/jeremyphoward/status/1902109312216129905?t=1eHFv2vdNdAckajnug0_Vw&s=19). However, you can utilize `llms.txt` effectively through an MCP server.
We provide an MCP server specifically designed to serve documentation, called [`mcpdoc`](https://github.com/langchain-ai/mcpdoc). This setup is compatible with IDEs and platforms such as Cursor, Windsurf, Claude, and Claude Code. Instructions for using `mcpdoc` with these tools are available in the repository.
## Using `llms-full.txt`
The LangGraph `llms-full.txt` file typically contains several hundred thousand tokens, exceeding the context window limitations of most LLMs. To effectively use this file:
1. **With IDEs (e.g., Cursor, Windsurf)**:
- Add the `llms-full.txt` as custom documentation. The IDE will automatically chunk and index the content, implementing Retrieval-Augmented Generation (RAG).
2. **Without IDE support**:
- Use a chat model with a large context window.
- Implement a RAG strategy to manage and query the documentation efficiently.
+5
View File
@@ -1,3 +1,8 @@
---
search:
boost: 2
---
# Deployment
Get started deploying your LangGraph applications locally or on the cloud with
+4 -2
View File
@@ -54,7 +54,7 @@ theme:
code: "Roboto Mono"
plugins:
- search:
separator: '[\s\u200b\-_,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
separator: '[\s\u200b\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;'
- autorefs
- mkdocstrings:
handlers:
@@ -85,7 +85,7 @@ plugins:
nav:
- Home:
- Introduction: index.md
- index.md
- Get started:
- Learn the basics: tutorials/introduction.ipynb
- Deployment:
@@ -231,6 +231,7 @@ nav:
- cloud/how-tos/stream_debug.md
- cloud/how-tos/stream_multiple.md
- cloud/how-tos/use_stream_react.md
- cloud/how-tos/generative_ui_react.md
- Human-in-the-loop:
- Human-in-the-loop: how-tos#human-in-the-loop_1
- cloud/how-tos/human_in_the_loop_breakpoint.md
@@ -360,6 +361,7 @@ nav:
# NOTE: prebuilt.md is auto-generated by `make build-prebuilt`
- Prebuilt Agents: prebuilt.md
- Companies using LangGraph: adopters.md
- LLMS-txt: llms-txt-overview.md
- FAQ: concepts/faq.md
- Troubleshooting:
- Troubleshooting: troubleshooting/errors/index.md
@@ -2,6 +2,7 @@ import asyncio
import logging
from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager
from types import TracebackType
from typing import Any, Callable, Optional, Union, cast
import orjson
@@ -25,6 +26,7 @@ from langgraph.store.postgres.base import (
PoolConfig,
PostgresIndexConfig,
Row,
TTLConfig,
_decode_ns_bytes,
_ensure_index_config,
_group_ops,
@@ -106,6 +108,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
Semantic search is disabled by default. You can enable it by providing an `index` configuration
when creating the store. Without this configuration, all `index` arguments passed to
`put` or `aput` will have no effect.
Note:
If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin
the background task that removes expired items. Call `stop_ttl_sweeper()` to properly
clean up resources when you're done with the store.
"""
__slots__ = (
@@ -115,7 +122,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
"supports_pipeline",
"index_config",
"embeddings",
"ttl_config",
"_ttl_sweeper_task",
"_ttl_stop_event",
)
supports_ttl: bool = True
def __init__(
self,
@@ -126,6 +137,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> None:
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
raise ValueError(
@@ -141,10 +153,13 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
self.index_config = index
if self.index_config:
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
else:
self.embeddings = None
self.ttl_config = ttl
self._ttl_sweeper_task: Optional[asyncio.Task[None]] = None
self._ttl_stop_event = asyncio.Event()
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
grouped_ops, num_ops = _group_ops(ops)
results: list[Result] = [None] * num_ops
@@ -167,6 +182,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
pipeline: bool = False,
pool_config: Optional[PoolConfig] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> AsyncIterator["AsyncPostgresStore"]:
"""Create a new AsyncPostgresStore instance from a connection string.
@@ -198,16 +214,16 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
**cast(dict, pc),
),
) as pool:
yield cls(conn=pool, index=index)
yield cls(conn=pool, index=index, ttl=ttl)
else:
async with await AsyncConnection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
if pipeline:
async with conn.pipeline() as pipe:
yield cls(conn=conn, pipe=pipe, index=index)
yield cls(conn=conn, pipe=pipe, index=index, ttl=ttl)
else:
yield cls(conn=conn, index=index)
yield cls(conn=conn, index=index, ttl=ttl)
async def setup(self) -> None:
"""Set up the store database asynchronously.
@@ -256,6 +272,119 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
"INSERT INTO vector_migrations (v) VALUES (%s)", (v,)
)
async def sweep_ttl(self) -> int:
"""Delete expired store items based on TTL.
Returns:
int: The number of deleted items.
"""
async with self._cursor() as cur:
await cur.execute(
"""
DELETE FROM store
WHERE expires_at IS NOT NULL AND expires_at < NOW()
"""
)
deleted_count = cur.rowcount
return deleted_count
async def start_ttl_sweeper(
self, sweep_interval_minutes: Optional[int] = None
) -> asyncio.Task[None]:
"""Periodically delete expired store items based on TTL.
Returns:
Task that can be awaited or cancelled.
"""
if not self.ttl_config:
return asyncio.create_task(asyncio.sleep(0))
if self._ttl_sweeper_task is not None and not self._ttl_sweeper_task.done():
return self._ttl_sweeper_task
self._ttl_stop_event.clear()
interval = float(
sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
)
logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
async def _sweep_loop() -> None:
while not self._ttl_stop_event.is_set():
try:
try:
await asyncio.wait_for(
self._ttl_stop_event.wait(),
timeout=interval * 60,
)
break
except asyncio.TimeoutError:
pass
expired_items = await self.sweep_ttl()
if expired_items > 0:
logger.info(f"Store swept {expired_items} expired items")
except asyncio.CancelledError:
break
except Exception as exc:
logger.exception("Store TTL sweep iteration failed", exc_info=exc)
task = asyncio.create_task(_sweep_loop())
task.set_name("ttl_sweeper")
self._ttl_sweeper_task = task
return task
async def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
"""Stop the TTL sweeper task if it's running.
Args:
timeout: Maximum time to wait for the task to stop, in seconds.
If None, wait indefinitely.
Returns:
bool: True if the task was successfully stopped or wasn't running,
False if the timeout was reached before the task stopped.
"""
if self._ttl_sweeper_task is None or self._ttl_sweeper_task.done():
return True
logger.info("Stopping TTL sweeper task")
self._ttl_stop_event.set()
if timeout is not None:
try:
await asyncio.wait_for(self._ttl_sweeper_task, timeout=timeout)
success = True
except asyncio.TimeoutError:
success = False
else:
await self._ttl_sweeper_task
success = True
if success:
self._ttl_sweeper_task = None
logger.info("TTL sweeper task stopped")
else:
logger.warning("Timed out waiting for TTL sweeper task to stop")
return success
async def __aenter__(self) -> "AsyncPostgresStore":
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional["TracebackType"],
) -> None:
# Ensure the TTL sweeper task is stopped when exiting the context
if hasattr(self, "_ttl_sweeper_task") and self._ttl_sweeper_task is not None:
# Set the event to signal the task to stop
self._ttl_stop_event.set()
# We don't wait for the task to complete here to avoid blocking
# The task will clean up itself gracefully
async def _execute_batch(
self,
grouped_ops: dict,
@@ -1,4 +1,5 @@
import asyncio
import concurrent.futures
import json
import logging
import threading
@@ -74,6 +75,17 @@ CREATE TABLE IF NOT EXISTS store (
"""
-- For faster lookups by prefix
CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
""",
"""
-- Add expires_at column to store table
ALTER TABLE store
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS ttl_minutes INT;
""",
"""
-- Add indexes for efficient TTL sweeping
CREATE INDEX IF NOT EXISTS idx_store_expires_at ON store (expires_at)
WHERE expires_at IS NOT NULL;
""",
]
@@ -225,20 +237,55 @@ class BasePostgresStore(Generic[C]):
self,
get_ops: Sequence[tuple[int, GetOp]],
) -> list[tuple[str, tuple, tuple[str, ...], list]]:
"""
Build queries to fetch (and optionally refresh the TTL of) multiple keys per namespace.
Each returned element is a tuple of:
(sql_query_string, sql_params, namespace, items_for_this_namespace)
where items_for_this_namespace is the original list of (idx, key, refresh_ttl).
"""
namespace_groups = defaultdict(list)
refresh_ttls = defaultdict(list)
for idx, op in get_ops:
namespace_groups[op.namespace].append((idx, op.key))
refresh_ttls[op.namespace].append(op.refresh_ttl)
results = []
for namespace, items in namespace_groups.items():
_, keys = zip(*items)
keys_to_query = ",".join(["%s"] * len(keys))
query = f"""
SELECT key, value, created_at, updated_at
FROM store
WHERE prefix = %s AND key IN ({keys_to_query})
this_refresh_ttls = refresh_ttls[namespace]
query = """
WITH passed_in AS (
SELECT unnest(%s::text[]) AS key,
unnest(%s::bool[]) AS do_refresh
),
updated AS (
UPDATE store s
SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval
FROM passed_in p
WHERE s.prefix = %s
AND s.key = p.key
AND p.do_refresh = TRUE
AND s.ttl_minutes IS NOT NULL
RETURNING s.key
)
SELECT s.key, s.value, s.created_at, s.updated_at
FROM store s
JOIN passed_in p ON s.key = p.key
WHERE s.prefix = %s
"""
params = (_namespace_to_text(namespace), *keys)
ns_text = _namespace_to_text(namespace)
params = (
list(keys), # -> unnest(%s::text[])
list(this_refresh_ttls), # -> unnest(%s::bool[])
ns_text, # -> prefix = %s (for UPDATE)
ns_text, # -> prefix = %s (for final SELECT)
)
results.append((query, params, namespace, items))
return results
def _prepare_batch_PUT_queries(
@@ -248,7 +295,6 @@ class BasePostgresStore(Generic[C]):
list[tuple[str, Sequence]],
Optional[tuple[str, Sequence[tuple[str, str, str, str]]]],
]:
# Last-write wins
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
for _, op in put_ops:
dedupped_ops[(op.namespace, op.key)] = op
@@ -282,15 +328,26 @@ class BasePostgresStore(Generic[C]):
insertion_params = []
vector_values = []
embedding_request_params = []
# Handle TTL expiration
# First handle main store insertions
for op in inserts:
values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
if op.ttl is not None:
expires_at_str = f"NOW() + INTERVAL '{op.ttl*60} seconds'"
ttl_minutes = op.ttl
else:
expires_at_str = "NULL"
ttl_minutes = None
values.append(
f"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, {expires_at_str}, %s)"
)
insertion_params.extend(
[
_namespace_to_text(op.namespace),
op.key,
Jsonb(cast(dict, op.value)),
ttl_minutes,
]
)
@@ -304,7 +361,7 @@ class BasePostgresStore(Generic[C]):
k = op.key
if op.index is None:
paths = self.index_config["__tokenized_fields"]
paths = cast(dict, self.index_config)["__tokenized_fields"]
else:
paths = [(ix, tokenize_path(ix)) for ix in op.index]
@@ -319,11 +376,13 @@ class BasePostgresStore(Generic[C]):
values_str = ",".join(values)
query = f"""
INSERT INTO store (prefix, key, value, created_at, updated_at)
INSERT INTO store (prefix, key, value, created_at, updated_at, expires_at, ttl_minutes)
VALUES {values_str}
ON CONFLICT (prefix, key) DO UPDATE
SET value = EXCLUDED.value,
updated_at = CURRENT_TIMESTAMP
updated_at = CURRENT_TIMESTAMP,
expires_at = EXCLUDED.expires_at,
ttl_minutes = EXCLUDED.ttl_minutes
"""
queries.append((query, insertion_params))
@@ -347,92 +406,105 @@ class BasePostgresStore(Generic[C]):
list[tuple[str, list[Union[None, str, list[float]]]]], # queries, params
list[tuple[int, str]], # idx, query_text pairs to embed
]:
"""
Build per-SearchOp SQL queries (with optional TTL refresh) plus embedding requests.
Returns:
- queries: list of (SQL, param_list)
- embedding_requests: list of (original_index_in_search_ops, text_query)
"""
queries = []
embedding_requests = []
for idx, (_, op) in enumerate(search_ops):
# Build filter conditions first
filter_params = []
filter_conditions = []
filter_clauses = []
if op.filter:
for key, value in op.filter.items():
if isinstance(value, dict):
for op_name, val in value.items():
condition, filter_params_ = self._get_filter_condition(
condition, params_ = self._get_filter_condition(
key, op_name, val
)
filter_conditions.append(condition)
filter_params.extend(filter_params_)
filter_clauses.append(condition)
filter_params.extend(params_)
else:
filter_conditions.append("value->%s = %s::jsonb")
filter_params.extend([key, json.dumps(value)])
filter_clauses.append("value->%s = %s::jsonb")
filter_params.extend([key, orjson.dumps(value).decode("utf-8")])
ns_condition = "TRUE"
ns_param: Optional[Sequence[Union[str]]] = None
if op.namespace_prefix:
ns_condition = "store.prefix LIKE %s"
ns_param = (f"{_namespace_to_text(op.namespace_prefix)}%",)
else:
ns_param = ()
extra_filters = (
" AND " + " AND ".join(filter_clauses) if filter_clauses else ""
)
# Vector search branch
if op.query and self.index_config:
# We'll embed the text later, so record the request.
embedding_requests.append((idx, op.query))
score_operator, post_operator = get_distance_operator(self)
post_operator = post_operator.replace("scored", "uniq")
vector_type = (
cast(PostgresIndexConfig, self.index_config)
.get("ann_index_config", {})
.get("vector_type", "vector")
)
# For hamming bit vectors, or “regular” vectors
if (
vector_type == "bit"
and self.index_config.get("distance_type") == "hamming"
and cast(dict, self.index_config).get("distance_type") == "hamming"
):
score_operator = score_operator % (
"%s",
self.index_config["dims"],
cast(dict, self.index_config)["dims"],
)
else:
score_operator = score_operator % (
"%s",
vector_type,
)
score_operator = score_operator % ("%s", vector_type)
vectors_per_doc_estimate = self.index_config["__estimated_num_vectors"]
vectors_per_doc_estimate = cast(dict, self.index_config)[
"__estimated_num_vectors"
]
expanded_limit = (op.limit * vectors_per_doc_estimate * 2) + 1
# Vector search with CTE for proper score handling
filter_str = (
""
if not filter_conditions
else " AND " + " AND ".join(filter_conditions)
)
if op.namespace_prefix:
prefix_filter_str = f"WHERE s.prefix LIKE %s {filter_str} "
ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
else:
ns_args = ()
if filter_str:
prefix_filter_str = f"WHERE {filter_str} "
else:
prefix_filter_str = ""
base_query = f"""
WITH scored AS (
SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, {score_operator} AS neg_score
FROM store s
JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
{prefix_filter_str}
ORDER BY {score_operator} ASC
# “sub_scored” does the main vector search
# Then we do DISTINCT ON to drop duplicates if your store can have them
# Finally we limit & offset
vector_search_cte = f"""
SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at,
{score_operator} AS neg_score
FROM store
JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key
WHERE {ns_condition} {extra_filters}
ORDER BY {score_operator} ASC
LIMIT %s
)
SELECT * FROM (
SELECT DISTINCT ON (prefix, key)
prefix, key, value, created_at, updated_at, {post_operator} as score
FROM scored
ORDER BY prefix, key, score DESC
) AS unique_docs
ORDER BY score DESC
LIMIT %s
OFFSET %s
"""
params = [
PLACEHOLDER, # Vector placeholder
*ns_args,
"""
search_results_sql = f"""
WITH scored AS (
{vector_search_cte}
)
SELECT uniq.prefix, uniq.key, uniq.value, uniq.created_at, uniq.updated_at,
{post_operator} AS score
FROM (
SELECT DISTINCT ON (scored.prefix, scored.key)
scored.prefix, scored.key, scored.value, scored.created_at, scored.updated_at, scored.neg_score
FROM scored
ORDER BY scored.prefix, scored.key, scored.neg_score ASC
) uniq
ORDER BY score DESC
LIMIT %s
OFFSET %s
"""
search_results_params = [
PLACEHOLDER,
*ns_param,
*filter_params,
PLACEHOLDER,
expanded_limit,
@@ -440,24 +512,45 @@ class BasePostgresStore(Generic[C]):
op.offset,
]
# Regular search branch
else:
base_query = """
SELECT prefix, key, value, created_at, updated_at
FROM store
WHERE prefix LIKE %s
"""
params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
base_query = f"""
SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score
FROM store
WHERE {ns_condition} {extra_filters}
ORDER BY store.updated_at DESC
LIMIT %s
OFFSET %s
"""
search_results_sql = base_query
search_results_params = [
*ns_param,
*filter_params,
op.limit,
op.offset,
]
if filter_conditions:
params.extend(filter_params)
base_query += " AND " + " AND ".join(filter_conditions)
base_query += " ORDER BY updated_at DESC"
base_query += " LIMIT %s OFFSET %s"
params.extend([op.limit, op.offset])
queries.append((base_query, params))
if op.refresh_ttl:
# Wrap entire primary query in a CTE, then perform "update_at"
final_sql = f"""
WITH search_results AS (
{search_results_sql}
),
updated AS (
UPDATE store s
SET expires_at = NOW() + (s.ttl_minutes || ' minutes')::interval
FROM search_results sr
WHERE s.prefix = sr.prefix
AND s.key = sr.key
AND s.ttl_minutes IS NOT NULL
)
SELECT sr.prefix, sr.key, sr.value, sr.created_at, sr.updated_at, sr.score
FROM search_results sr
"""
final_params = search_results_params[:] # copy
else:
final_sql = search_results_sql
final_params = search_results_params
queries.append((final_sql, final_params))
return queries, embedding_requests
@@ -603,6 +696,11 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
Make sure to call `setup()` before first use to create necessary tables and indexes.
The pgvector extension must be available to use vector search.
Note:
If you provide a TTL configuration, you must explicitly call `start_ttl_sweeper()` to begin
the background thread that removes expired items. Call `stop_ttl_sweeper()` to properly
clean up resources when you're done with the store.
"""
__slots__ = (
@@ -612,7 +710,10 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
"supports_pipeline",
"index_config",
"embeddings",
"_ttl_sweeper_thread",
"_ttl_stop_event",
)
supports_ttl: bool = True
def __init__(
self,
@@ -637,6 +738,8 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
else:
self.embeddings = None
self.ttl_config = ttl
self._ttl_sweeper_thread: Optional[threading.Thread] = None
self._ttl_stop_event = threading.Event()
@classmethod
@contextmanager
@@ -647,6 +750,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
pipeline: bool = False,
pool_config: Optional[PoolConfig] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> Iterator["PostgresStore"]:
"""Create a new PostgresStore instance from a connection string.
@@ -678,16 +782,123 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
**cast(dict, pc),
),
) as pool:
yield cls(conn=pool, index=index)
yield cls(conn=pool, index=index, ttl=ttl)
else:
with Connection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
if pipeline:
with conn.pipeline() as pipe:
yield cls(conn, pipe=pipe, index=index)
yield cls(conn, pipe=pipe, index=index, ttl=ttl)
else:
yield cls(conn, index=index)
yield cls(conn, index=index, ttl=ttl)
def sweep_ttl(self) -> int:
"""Delete expired store items based on TTL.
Returns:
int: The number of deleted items.
"""
with self._cursor() as cur:
cur.execute(
"""
DELETE FROM store
WHERE expires_at IS NOT NULL AND expires_at < NOW()
"""
)
deleted_count = cur.rowcount
return deleted_count
def start_ttl_sweeper(
self, sweep_interval_minutes: Optional[int] = None
) -> concurrent.futures.Future[None]:
"""Periodically delete expired store items based on TTL.
Returns:
Future that can be waited on or cancelled.
"""
if not self.ttl_config:
future: concurrent.futures.Future[None] = concurrent.futures.Future()
future.set_result(None)
return future
if self._ttl_sweeper_thread and self._ttl_sweeper_thread.is_alive():
logger.info("TTL sweeper thread is already running")
# Return a future that can be used to cancel the existing thread
future = concurrent.futures.Future()
future.add_done_callback(
lambda f: self._ttl_stop_event.set() if f.cancelled() else None
)
return future
self._ttl_stop_event.clear()
interval = float(
sweep_interval_minutes or self.ttl_config.get("sweep_interval_minutes") or 5
)
logger.info(f"Starting store TTL sweeper with interval {interval} minutes")
future = concurrent.futures.Future()
def _sweep_loop() -> None:
try:
while not self._ttl_stop_event.is_set():
if self._ttl_stop_event.wait(interval * 60):
break
try:
expired_items = self.sweep_ttl()
if expired_items > 0:
logger.info(f"Store swept {expired_items} expired items")
except Exception as exc:
logger.exception(
"Store TTL sweep iteration failed", exc_info=exc
)
future.set_result(None)
except Exception as exc:
future.set_exception(exc)
thread = threading.Thread(target=_sweep_loop, daemon=True, name="ttl-sweeper")
self._ttl_sweeper_thread = thread
thread.start()
future.add_done_callback(
lambda f: self._ttl_stop_event.set() if f.cancelled() else None
)
return future
def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
"""Stop the TTL sweeper thread if it's running.
Args:
timeout: Maximum time to wait for the thread to stop, in seconds.
If None, wait indefinitely.
Returns:
bool: True if the thread was successfully stopped or wasn't running,
False if the timeout was reached before the thread stopped.
"""
if not self._ttl_sweeper_thread or not self._ttl_sweeper_thread.is_alive():
return True
logger.info("Stopping TTL sweeper thread")
self._ttl_stop_event.set()
self._ttl_sweeper_thread.join(timeout)
success = not self._ttl_sweeper_thread.is_alive()
if success:
self._ttl_sweeper_thread = None
logger.info("TTL sweeper thread stopped")
else:
logger.warning("Timed out waiting for TTL sweeper thread to stop")
return success
def __del__(self) -> None:
"""Ensure the TTL sweeper thread is stopped when the object is garbage collected."""
if hasattr(self, "_ttl_stop_event") and hasattr(self, "_ttl_sweeper_thread"):
self.stop_ttl_sweeper(timeout=0.1)
@contextmanager
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
@@ -886,8 +1097,14 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
with self._cursor() as cur:
version = _get_version(cur, table="store_migrations")
for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
cur.execute(sql)
cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
try:
cur.execute(sql)
cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
except Exception as e:
logger.error(
f"Failed to apply migration {v}.\nSql={sql}\nError={e}"
)
raise
if self.index_config:
version = _get_version(cur, table="vector_migrations")
+2 -2
View File
@@ -397,7 +397,7 @@ typing-extensions = ">=4.7"
[[package]]
name = "langgraph-checkpoint"
version = "2.0.18"
version = "2.0.21"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1404,4 +1404,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0,<4.0"
content-hash = "369bfffecb9489835b43b8255932e043176a11d2f639aad2d055ffd89263ca1e"
content-hash = "4b0efdd115566f294fcd876334f9c3787aafc81f2689473759d88189a71d4635"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-postgres"
version = "2.0.16"
version = "2.0.19"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
langgraph-checkpoint = "^2.0.15"
langgraph-checkpoint = "^2.0.21"
orjson = ">=3.10.1"
psycopg = "^3.2.0"
psycopg-pool = "^3.2.0"
@@ -26,6 +26,9 @@ from tests.conftest import (
CharacterEmbeddings,
)
TTL_SECONDS = 6
TTL_MINUTES = TTL_SECONDS / 60
@pytest.fixture(scope="function", params=["default", "pipe", "pool"])
async def store(request) -> AsyncIterator[AsyncPostgresStore]:
@@ -42,28 +45,54 @@ async def store(request) -> AsyncIterator[AsyncPostgresStore]:
conn_string = f"{uri_base}/{database}{query_params}"
admin_conn_string = DEFAULT_URI
ttl_config = {
"default_ttl": TTL_MINUTES,
"refresh_on_read": True,
"sweep_interval_minutes": TTL_MINUTES / 2,
}
async with await AsyncConnection.connect(
admin_conn_string, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with AsyncPostgresStore.from_conn_string(conn_string) as store:
async with AsyncPostgresStore.from_conn_string(
conn_string, ttl=ttl_config
) as store:
store.MIGRATIONS = [
(
mig.replace("ttl_minutes INT;", "ttl_minutes FLOAT;")
if isinstance(mig, str)
else mig
)
for mig in store.MIGRATIONS
]
await store.setup()
async with store._cursor() as cur:
# drop the migration index
await cur.execute("DROP TABLE IF EXISTS store_migrations")
await store.setup() # Will fail if migrations aren't idempotent
if request.param == "pipe":
async with AsyncPostgresStore.from_conn_string(
conn_string, pipeline=True
conn_string, pipeline=True, ttl=ttl_config
) as store:
await store.start_ttl_sweeper()
yield store
await store.stop_ttl_sweeper()
elif request.param == "pool":
async with AsyncPostgresStore.from_conn_string(
conn_string, pool_config={"min_size": 1, "max_size": 10}
conn_string, pool_config={"min_size": 1, "max_size": 10}, ttl=ttl_config
) as store:
await store.start_ttl_sweeper()
yield store
await store.stop_ttl_sweeper()
else: # default
async with AsyncPostgresStore.from_conn_string(conn_string) as store:
async with AsyncPostgresStore.from_conn_string(
conn_string, ttl=ttl_config
) as store:
await store.start_ttl_sweeper()
yield store
await store.stop_ttl_sweeper()
finally:
async with await AsyncConnection.connect(
admin_conn_string, autocommit=True
@@ -635,3 +664,28 @@ async def test_search_sorting(
assert len(set(r.key for r in results)) == 10
assert results[0].key == "M"
assert results[0].score > results[1].score
async def test_store_ttl(store):
# Assumes a TTL of 1 minute = 60 seconds
ns = ("foo",)
await store.start_ttl_sweeper()
await store.aput(
ns,
key="item1",
value={"foo": "bar"},
ttl=TTL_MINUTES, # type: ignore
)
await asyncio.sleep(TTL_SECONDS - 2)
res = await store.aget(ns, key="item1", refresh_ttl=True)
assert res is not None
await asyncio.sleep(TTL_SECONDS - 2)
results = await store.asearch(ns, query="foo", refresh_ttl=True)
assert len(results) == 1
await asyncio.sleep(TTL_SECONDS - 2)
res = await store.aget(ns, key="item1", refresh_ttl=False)
assert res is not None
await asyncio.sleep(TTL_SECONDS - 1)
# Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
results = await store.asearch(ns, query="bar", refresh_ttl=False)
assert len(results) == 0
+195 -125
View File
@@ -1,6 +1,7 @@
# type: ignore
import re
import time
from contextlib import contextmanager
from typing import Any, Optional
from uuid import uuid4
@@ -24,6 +25,9 @@ from tests.conftest import (
CharacterEmbeddings,
)
TTL_SECONDS = 6
TTL_MINUTES = TTL_SECONDS / 60
@pytest.fixture(scope="function", params=["default", "pipe", "pool"])
def store(request) -> PostgresStore:
@@ -32,29 +36,56 @@ def store(request) -> PostgresStore:
uri_base = "/".join(uri_parts[:-1])
query_params = ""
if "?" in uri_parts[-1]:
db_name, query_params = uri_parts[-1].split("?", 1)
_, query_params = uri_parts[-1].split("?", 1)
query_params = "?" + query_params
conn_string = f"{uri_base}/{database}{query_params}"
admin_conn_string = DEFAULT_URI
ttl_config = {
"default_ttl": TTL_MINUTES,
"refresh_on_read": True,
"sweep_interval_minutes": TTL_MINUTES / 2,
}
with Connection.connect(admin_conn_string, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
with PostgresStore.from_conn_string(conn_string) as store:
with PostgresStore.from_conn_string(conn_string, ttl=ttl_config) as store:
store.MIGRATIONS = [
(
mig.replace("ttl_minutes INT;", "ttl_minutes FLOAT;")
if isinstance(mig, str)
else mig
)
for mig in store.MIGRATIONS
]
store.setup()
if request.param == "pipe":
with PostgresStore.from_conn_string(conn_string, pipeline=True) as store:
with PostgresStore.from_conn_string(
conn_string,
pipeline=True,
ttl=ttl_config,
) as store:
store.start_ttl_sweeper()
yield store
store.stop_ttl_sweeper()
elif request.param == "pool":
with PostgresStore.from_conn_string(
conn_string, pool_config={"min_size": 1, "max_size": 10}
conn_string,
pool_config={"min_size": 1, "max_size": 10},
ttl=ttl_config,
) as store:
store.start_ttl_sweeper()
yield store
store.stop_ttl_sweeper()
else: # default
with PostgresStore.from_conn_string(conn_string) as store:
with PostgresStore.from_conn_string(conn_string, ttl=ttl_config) as store:
store.start_ttl_sweeper()
yield store
store.stop_ttl_sweeper()
finally:
with Connection.connect(admin_conn_string, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@@ -220,134 +251,127 @@ def test_batch_list_namespaces_ops(store: PostgresStore) -> None:
assert all(ns[-1] == "public" for ns in results[2])
class TestPostgresStore:
@pytest.fixture(autouse=True)
def setup(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
store.setup()
def test_basic_store_ops(store) -> None:
namespace = ("test", "documents")
item_id = "doc1"
item_value = {"title": "Test Document", "content": "Hello, World!"}
def test_basic_store_ops(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
namespace = ("test", "documents")
item_id = "doc1"
item_value = {"title": "Test Document", "content": "Hello, World!"}
store.put(namespace, item_id, item_value)
item = store.get(namespace, item_id)
store.put(namespace, item_id, item_value)
item = store.get(namespace, item_id)
assert item
assert item.namespace == namespace
assert item.key == item_id
assert item.value == item_value
assert item
assert item.namespace == namespace
assert item.key == item_id
assert item.value == item_value
# Test update
updated_value = {"title": "Updated Document", "content": "Hello, Updated!"}
store.put(namespace, item_id, updated_value)
updated_item = store.get(namespace, item_id)
# Test update
updated_value = {"title": "Updated Document", "content": "Hello, Updated!"}
store.put(namespace, item_id, updated_value)
updated_item = store.get(namespace, item_id)
assert updated_item.value == updated_value
assert updated_item.updated_at > item.updated_at
assert updated_item.value == updated_value
assert updated_item.updated_at > item.updated_at
# Test get from non-existent namespace
different_namespace = ("test", "other_documents")
item_in_different_namespace = store.get(different_namespace, item_id)
assert item_in_different_namespace is None
# Test get from non-existent namespace
different_namespace = ("test", "other_documents")
item_in_different_namespace = store.get(different_namespace, item_id)
assert item_in_different_namespace is None
# Test delete
store.delete(namespace, item_id)
deleted_item = store.get(namespace, item_id)
assert deleted_item is None
# Test delete
store.delete(namespace, item_id)
deleted_item = store.get(namespace, item_id)
assert deleted_item is None
def test_list_namespaces(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
# Create test data with various namespaces
test_namespaces = [
("test", "documents", "public"),
("test", "documents", "private"),
("test", "images", "public"),
("test", "images", "private"),
("prod", "documents", "public"),
("prod", "documents", "private"),
]
def test_list_namespaces(store) -> None:
# Create test data with various namespaces
test_namespaces = [
("test", "documents", "public"),
("test", "documents", "private"),
("test", "images", "public"),
("test", "images", "private"),
("prod", "documents", "public"),
("prod", "documents", "private"),
]
# Insert test data
for namespace in test_namespaces:
store.put(namespace, "dummy", {"content": "dummy"})
# Insert test data
for namespace in test_namespaces:
store.put(namespace, "dummy", {"content": "dummy"})
# Test listing with various filters
all_namespaces = store.list_namespaces()
assert len(all_namespaces) == len(test_namespaces)
# Test listing with various filters
all_namespaces = store.list_namespaces()
assert len(all_namespaces) == len(test_namespaces)
# Test prefix filtering
test_prefix_namespaces = store.list_namespaces(prefix=["test"])
assert len(test_prefix_namespaces) == 4
assert all(ns[0] == "test" for ns in test_prefix_namespaces)
# Test prefix filtering
test_prefix_namespaces = store.list_namespaces(prefix=["test"])
assert len(test_prefix_namespaces) == 4
assert all(ns[0] == "test" for ns in test_prefix_namespaces)
# Test suffix filtering
public_namespaces = store.list_namespaces(suffix=["public"])
assert len(public_namespaces) == 3
assert all(ns[-1] == "public" for ns in public_namespaces)
# Test suffix filtering
public_namespaces = store.list_namespaces(suffix=["public"])
assert len(public_namespaces) == 3
assert all(ns[-1] == "public" for ns in public_namespaces)
# Test max depth
depth_2_namespaces = store.list_namespaces(max_depth=2)
assert all(len(ns) <= 2 for ns in depth_2_namespaces)
# Test max depth
depth_2_namespaces = store.list_namespaces(max_depth=2)
assert all(len(ns) <= 2 for ns in depth_2_namespaces)
# Test pagination
paginated_namespaces = store.list_namespaces(limit=3)
assert len(paginated_namespaces) == 3
# Test pagination
paginated_namespaces = store.list_namespaces(limit=3)
assert len(paginated_namespaces) == 3
# Cleanup
for namespace in test_namespaces:
store.delete(namespace, "dummy")
# Cleanup
for namespace in test_namespaces:
store.delete(namespace, "dummy")
def test_search(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
# Create test data
test_data = [
(
("test", "docs"),
"doc1",
{"title": "First Doc", "author": "Alice", "tags": ["important"]},
),
(
("test", "docs"),
"doc2",
{"title": "Second Doc", "author": "Bob", "tags": ["draft"]},
),
(
("test", "images"),
"img1",
{"title": "Image 1", "author": "Alice", "tags": ["final"]},
),
]
for namespace, key, value in test_data:
store.put(namespace, key, value)
def test_search(store) -> None:
# Create test data
test_data = [
(
("test", "docs"),
"doc1",
{"title": "First Doc", "author": "Alice", "tags": ["important"]},
),
(
("test", "docs"),
"doc2",
{"title": "Second Doc", "author": "Bob", "tags": ["draft"]},
),
(
("test", "images"),
"img1",
{"title": "Image 1", "author": "Alice", "tags": ["final"]},
),
]
# Test basic search
all_items = store.search(["test"])
assert len(all_items) == 3
for namespace, key, value in test_data:
store.put(namespace, key, value)
# Test namespace filtering
docs_items = store.search(["test", "docs"])
assert len(docs_items) == 2
assert all(item.namespace == ("test", "docs") for item in docs_items)
# Test basic search
all_items = store.search(["test"])
assert len(all_items) == 3
# Test value filtering
alice_items = store.search(["test"], filter={"author": "Alice"})
assert len(alice_items) == 2
assert all(item.value["author"] == "Alice" for item in alice_items)
# Test namespace filtering
docs_items = store.search(["test", "docs"])
assert len(docs_items) == 2
assert all(item.namespace == ("test", "docs") for item in docs_items)
# Test pagination
paginated_items = store.search(["test"], limit=2)
assert len(paginated_items) == 2
# Test value filtering
alice_items = store.search(["test"], filter={"author": "Alice"})
assert len(alice_items) == 2
assert all(item.value["author"] == "Alice" for item in alice_items)
offset_items = store.search(["test"], offset=2)
assert len(offset_items) == 1
# Test pagination
paginated_items = store.search(["test"], limit=2)
assert len(paginated_items) == 2
# Cleanup
for namespace, key, _ in test_data:
store.delete(namespace, key)
offset_items = store.search(["test"], offset=2)
assert len(offset_items) == 1
# Cleanup
for namespace, key, _ in test_data:
store.delete(namespace, key)
@contextmanager
@@ -356,6 +380,7 @@ def _create_vector_store(
distance_type: str,
fake_embeddings: Embeddings,
text_fields: Optional[list[str]] = None,
enable_ttl: bool = True,
) -> PostgresStore:
"""Create a store with vector search enabled."""
database = f"test_{uuid4().hex[:16]}"
@@ -385,23 +410,32 @@ def _create_vector_store(
with PostgresStore.from_conn_string(
conn_string,
index=index_config,
ttl={"default_ttl": 2, "refresh_on_read": True} if enable_ttl else None,
) as store:
store.setup()
with store._cursor() as cur:
# drop the migration index
cur.execute("DROP TABLE IF EXISTS store_migrations")
store.setup() # Will fail if migrations aren't idempotent
yield store
finally:
with Connection.connect(admin_conn_string, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
_vector_params = [
(vector_type, distance_type, True)
for vector_type in VECTOR_TYPES
for distance_type in (
["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
)
]
_vector_params += [(*_vector_params[-1][:2], False)]
@pytest.fixture(
scope="function",
params=[
(vector_type, distance_type)
for vector_type in VECTOR_TYPES
for distance_type in (
["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
)
],
params=_vector_params,
ids=lambda p: f"{p[0]}_{p[1]}",
)
def vector_store(
@@ -409,8 +443,10 @@ def vector_store(
fake_embeddings: Embeddings,
) -> PostgresStore:
"""Create a store with vector search enabled."""
vector_type, distance_type = request.param
with _create_vector_store(vector_type, distance_type, fake_embeddings) as store:
vector_type, distance_type, enable_ttl = request.param
with _create_vector_store(
vector_type, distance_type, fake_embeddings, enable_ttl=enable_ttl
) as store:
yield store
@@ -474,7 +510,10 @@ def test_vector_update_with_embedding(vector_store: PostgresStore) -> None:
assert not any(r.key == "doc4" for r in results_new)
def test_vector_search_with_filters(vector_store: PostgresStore) -> None:
@pytest.mark.parametrize("refresh_ttl", [True, False])
def test_vector_search_with_filters(
vector_store: PostgresStore, refresh_ttl: bool
) -> None:
"""Test combining vector search with filters."""
# Insert test documents
docs = [
@@ -487,16 +526,23 @@ def test_vector_search_with_filters(vector_store: PostgresStore) -> None:
for key, value in docs:
vector_store.put(("test",), key, value)
results = vector_store.search(("test",), query="apple", filter={"color": "red"})
results = vector_store.search(
("test",), query="apple", filter={"color": "red"}, refresh_ttl=refresh_ttl
)
assert len(results) == 2
assert results[0].key == "doc1"
results = vector_store.search(("test",), query="car", filter={"color": "red"})
results = vector_store.search(
("test",), query="car", filter={"color": "red"}, refresh_ttl=refresh_ttl
)
assert len(results) == 2
assert results[0].key == "doc2"
results = vector_store.search(
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
("test",),
query="bbbbluuu",
filter={"score": {"$gt": 3.2}},
refresh_ttl=refresh_ttl,
)
assert len(results) == 3
assert results[0].key == "doc4"
@@ -688,7 +734,7 @@ def test_embed_with_path_operation_config(
store.put(("test",), "doc5", doc5, index=False)
results = store.search(("test",))
assert len(results) == 3
assert all(r.score is None for r in results)
assert all(r.score is None for r in results), f"{results}"
assert any(r.key == "doc5" for r in results)
results = store.search(("test",), query="hhh")
@@ -790,3 +836,27 @@ def test_nonnull_migrations() -> None:
for migration in PostgresStore.MIGRATIONS:
statement = _leading_comment_remover.sub("", migration).split()[0]
assert statement.strip()
def test_store_ttl(store):
# Assumes a TTL of 1 minute = 60 seconds
ns = ("foo",)
store.put(
ns,
key="item1",
value={"foo": "bar"},
ttl=TTL_MINUTES, # type: ignore
)
time.sleep(TTL_SECONDS - 2)
res = store.get(ns, key="item1", refresh_ttl=True)
assert res is not None
time.sleep(TTL_SECONDS - 2)
results = store.search(ns, query="foo", refresh_ttl=True)
assert len(results) == 1
time.sleep(TTL_SECONDS - 2)
res = store.get(ns, key="item1", refresh_ttl=False)
assert res is not None
time.sleep(TTL_SECONDS - 1)
# Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
res = store.search(ns, query="bar", refresh_ttl=False)
assert len(res) == 0
@@ -56,7 +56,10 @@ class SqliteSaver(BaseCheckpointSaver[str]):
>>> builder.add_node("add_one", lambda x: x + 1)
>>> builder.set_entry_point("add_one")
>>> builder.set_finish_point("add_one")
>>> conn = sqlite3.connect("checkpoints.sqlite")
>>> # Create a new SqliteSaver instance
>>> # Note: check_same_thread=False is OK as the implementation uses a lock
>>> # to ensure thread safety.
>>> conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
>>> memory = SqliteSaver(conn)
>>> graph = builder.compile(checkpointer=memory)
>>> config = {"configurable": {"thread_id": "1"}}
@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
from types import TracebackType
from typing import Any, Optional
from typing import Any, Optional, Union
from langchain_core.runnables import RunnableConfig
@@ -70,6 +70,12 @@ class InMemorySaver(
tuple[str, str, str],
dict[tuple[str, int], tuple[str, str, tuple[str, bytes], str]],
]
blobs: dict[
tuple[
str, str, str, Union[str, int, float]
], # thread id, checkpoint ns, channel, version
tuple[str, bytes],
]
def __init__(
self,
@@ -80,6 +86,7 @@ class InMemorySaver(
super().__init__(serde=serde)
self.storage = factory(lambda: defaultdict(dict))
self.writes = factory(dict)
self.blobs = factory()
self.stack = ExitStack()
if factory is not defaultdict:
self.stack.enter_context(self.storage) # type: ignore[arg-type]
@@ -107,6 +114,18 @@ class InMemorySaver(
) -> Optional[bool]:
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
def _load_blobs(
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
) -> dict[str, Any]:
channel_values: dict[str, Any] = {}
for k, v in versions.items():
kk = (thread_id, checkpoint_ns, k, v)
if kk in self.blobs:
vv = self.blobs[kk]
if vv[0] != "empty":
channel_values[k] = self.serde.loads_typed(vv)
return channel_values
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the in-memory storage.
@@ -121,8 +140,8 @@ class InMemorySaver(
Returns:
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
thread_id: str = config["configurable"]["thread_id"]
checkpoint_ns: str = config["configurable"].get("checkpoint_ns", "")
if checkpoint_id := get_checkpoint_id(config):
if saved := self.storage[thread_id][checkpoint_ns].get(checkpoint_id):
checkpoint, metadata, parent_checkpoint_id = saved
@@ -140,10 +159,14 @@ class InMemorySaver(
)
else:
sends = []
checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint)
return CheckpointTuple(
config=config,
checkpoint={
**self.serde.loads_typed(checkpoint),
**checkpoint_,
"channel_values": self._load_blobs(
thread_id, checkpoint_ns, checkpoint_["channel_versions"]
),
"pending_sends": [self.serde.loads_typed(s[2]) for s in sends],
},
metadata=self.serde.loads_typed(metadata),
@@ -180,6 +203,9 @@ class InMemorySaver(
)
else:
sends = []
checkpoint_ = self.serde.loads_typed(checkpoint)
return CheckpointTuple(
config={
"configurable": {
@@ -189,7 +215,10 @@ class InMemorySaver(
}
},
checkpoint={
**self.serde.loads_typed(checkpoint),
**checkpoint_,
"channel_values": self._load_blobs(
thread_id, checkpoint_ns, checkpoint_["channel_versions"]
),
"pending_sends": [self.serde.loads_typed(s[2]) for s in sends],
},
metadata=self.serde.loads_typed(metadata),
@@ -297,6 +326,8 @@ class InMemorySaver(
else:
sends = []
checkpoint_: Checkpoint = self.serde.loads_typed(checkpoint)
yield CheckpointTuple(
config={
"configurable": {
@@ -306,7 +337,12 @@ class InMemorySaver(
}
},
checkpoint={
**self.serde.loads_typed(checkpoint),
**checkpoint_,
"channel_values": self._load_blobs(
thread_id,
checkpoint_ns,
checkpoint_["channel_versions"],
),
"pending_sends": [
self.serde.loads_typed(s[2]) for s in sends
],
@@ -353,6 +389,11 @@ class InMemorySaver(
c.pop("pending_sends") # type: ignore[misc]
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"]
values: dict[str, Any] = c.pop("channel_values") # type: ignore[misc]
for k, v in new_versions.items():
self.blobs[(thread_id, checkpoint_ns, k, v)] = (
self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
)
self.storage[thread_id][checkpoint_ns].update(
{
checkpoint["id"]: (
@@ -45,3 +45,18 @@ def maybe_add_typed_methods(serde: SerializerProtocol) -> SerializerProtocol:
return SerializerCompat(serde)
return serde
class CipherProtocol(Protocol):
"""Protocol for encryption and decryption of data.
- `encrypt`: Encrypt plaintext.
- `decrypt`: Decrypt ciphertext.
"""
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
"""Encrypt plaintext. Returns a tuple (cipher name, ciphertext)."""
...
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
"""Decrypt ciphertext. Returns the plaintext."""
...
@@ -0,0 +1,86 @@
import os
from typing import Any
from langgraph.checkpoint.serde.base import CipherProtocol, SerializerProtocol
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
class EncryptedSerializer(SerializerProtocol):
"""Serializer that encrypts and decrypts data using an encryption protocol."""
def __init__(
self, cipher: CipherProtocol, serde: SerializerProtocol = JsonPlusSerializer()
) -> None:
self.cipher = cipher
self.serde = serde
def dumps(self, obj: Any) -> bytes:
return self.serde.dumps(obj)
def loads(self, data: bytes) -> Any:
return self.serde.loads(data)
def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
"""Serialize an object to a tuple (type, bytes) and encrypt the bytes."""
# serialize data
typ, data = self.serde.dumps_typed(obj)
# encrypt data
ciphername, ciphertext = self.cipher.encrypt(data)
# add cipher name to type
return f"{typ}+{ciphername}", ciphertext
def loads_typed(self, data: tuple[str, bytes]) -> Any:
enc_cipher, ciphertext = data
# unencrypted data
if "+" not in enc_cipher:
return self.serde.loads_typed(data)
# extract cipher name
typ, ciphername = enc_cipher.split("+", 1)
# decrypt data
decrypted_data = self.cipher.decrypt(ciphername, ciphertext)
# deserialize data
return self.serde.loads_typed((typ, decrypted_data))
@classmethod
def from_pycryptodome_aes(
cls, serde: SerializerProtocol = JsonPlusSerializer(), **kwargs: Any
) -> "EncryptedSerializer":
"""Create an EncryptedSerializer using AES encryption."""
try:
from Crypto.Cipher import AES # type: ignore
except ImportError:
raise ImportError(
"Pycryptodome is not installed. Please install it with `pip install pycryptodome`."
) from None
# check if AES key is provided
if "key" in kwargs:
key: bytes = kwargs.pop("key")
else:
key_str = os.getenv("LANGGRAPH_AES_KEY")
if key_str is None:
raise ValueError("LANGGRAPH_AES_KEY environment variable is not set.")
key = key_str.encode()
if len(key) not in (16, 24, 32):
raise ValueError("LANGGRAPH_AES_KEY must be 16, 24, or 32 bytes long.")
# set default mode to EAX if not provided
if kwargs.get("mode") is None:
kwargs["mode"] = AES.MODE_EAX
class PycryptodomeAesCipher(CipherProtocol):
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
cipher = AES.new(key, **kwargs)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
return "aes", cipher.nonce + tag + ciphertext
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
assert ciphername == "aes", f"Unsupported cipher: {ciphername}"
nonce = ciphertext[:16]
tag = ciphertext[16:32]
actual_ciphertext = ciphertext[32:]
cipher = AES.new(key, **kwargs, nonce=nonce)
return cipher.decrypt_and_verify(actual_ciphertext, tag)
return cls(PycryptodomeAesCipher(), serde)
@@ -537,6 +537,12 @@ class TTLConfig(TypedDict, total=False):
The expiration timer refreshes on both read and write operations.
Defaults to None (no expiration).
"""
sweep_interval_minutes: Optional[int]
"""Interval in minutes between TTL sweep operations.
If provided, the store will periodically delete expired items based on TTL.
Defaults to None (no sweeping).
"""
class IndexConfig(TypedDict, total=False):
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint"
version = "2.0.19"
version = "2.0.21"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
license = "MIT"
+39 -7
View File
@@ -68,7 +68,9 @@ class TestMemorySaver:
},
"metadata": {"run_id": "my_run_id"},
}
self.memory_saver.put(config, self.chkpnt_2, self.metadata_2, {})
self.memory_saver.put(
config, self.chkpnt_2, self.metadata_2, self.chkpnt_2["channel_versions"]
)
checkpoint = self.memory_saver.get_tuple(config)
assert checkpoint is not None
assert checkpoint.metadata == {
@@ -80,9 +82,24 @@ class TestMemorySaver:
async def test_search(self) -> None:
# set up test
# save checkpoints
self.memory_saver.put(self.config_1, self.chkpnt_1, self.metadata_1, {})
self.memory_saver.put(self.config_2, self.chkpnt_2, self.metadata_2, {})
self.memory_saver.put(self.config_3, self.chkpnt_3, self.metadata_3, {})
self.memory_saver.put(
self.config_1,
self.chkpnt_1,
self.metadata_1,
self.chkpnt_1["channel_versions"],
)
self.memory_saver.put(
self.config_2,
self.chkpnt_2,
self.metadata_2,
self.chkpnt_2["channel_versions"],
)
self.memory_saver.put(
self.config_3,
self.chkpnt_3,
self.metadata_3,
self.chkpnt_3["channel_versions"],
)
# call method / assertions
query_1 = {"source": "input"} # search by 1 key
@@ -129,9 +146,24 @@ class TestMemorySaver:
async def test_asearch(self) -> None:
# set up test
# save checkpoints
self.memory_saver.put(self.config_1, self.chkpnt_1, self.metadata_1, {})
self.memory_saver.put(self.config_2, self.chkpnt_2, self.metadata_2, {})
self.memory_saver.put(self.config_3, self.chkpnt_3, self.metadata_3, {})
self.memory_saver.put(
self.config_1,
self.chkpnt_1,
self.metadata_1,
self.chkpnt_1["channel_versions"],
)
self.memory_saver.put(
self.config_2,
self.chkpnt_2,
self.metadata_2,
self.chkpnt_2["channel_versions"],
)
self.memory_saver.put(
self.config_3,
self.chkpnt_3,
self.metadata_3,
self.chkpnt_3["channel_versions"],
)
# call method / assertions
query_1 = {"source": "input"} # search by 1 key
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: test lint format test-integration
.PHONY: test lint format test-integration update-schema
######################
# TESTING AND COVERAGE
@@ -31,3 +31,6 @@ lint lint_diff lint_package lint_tests:
format format_diff:
poetry run ruff format $(PYTHON_FILES)
poetry run ruff check --select I --fix $(PYTHON_FILES)
update-schema:
poetry run python generate_schema.py
+1 -1
View File
@@ -79,7 +79,7 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
}
```
See the [full documentation](https://langchain-ai.github.io/langgraph/docs/cloud/reference/cli.html) for detailed configuration options.
See the [full documentation](https://langchain-ai.github.io/langgraph/cloud/reference/cli/) for detailed configuration options.
## Development
+8
View File
@@ -574,6 +574,12 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
help="Wait for a debugger client to connect to the debug port before starting the server",
default=False,
)
@click.option(
"--studio-url",
type=str,
default=None,
help="URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com",
)
@cli.command(
"dev",
help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
@@ -588,6 +594,7 @@ def dev(
no_browser: bool,
debug_port: Optional[int],
wait_for_client: bool,
studio_url: Optional[str],
):
"""CLI entrypoint for running the LangGraph API server."""
try:
@@ -651,6 +658,7 @@ def dev(
wait_for_client=wait_for_client,
auth=config_json.get("auth"),
http=config_json.get("http"),
studio_url=studio_url,
)
+6
View File
@@ -27,6 +27,12 @@ class TTLConfig(TypedDict, total=False):
If provided, all new items will have this TTL unless explicitly overridden.
If omitted, items will have no TTL by default.
"""
sweep_interval_minutes: Optional[int]
"""Optional. Interval in minutes between TTL sweep iterations.
If provided, the store will periodically delete expired items based on the TTL.
If omitted, no automatic sweeping will occur.
"""
class IndexConfig(TypedDict, total=False):
+19 -19
View File
@@ -535,42 +535,42 @@ langgraph-sdk = ">=0.1.42,<0.2.0"
[[package]]
name = "langgraph-api"
version = "0.0.27"
version = "0.0.32"
description = ""
optional = true
python-versions = "<4.0,>=3.11.0"
files = [
{file = "langgraph_api-0.0.27-py3-none-any.whl", hash = "sha256:9b21742238b15b8db9c2d3fd760a670332c8897d0bcbbd9d82e43b6ac15a7937"},
{file = "langgraph_api-0.0.27.tar.gz", hash = "sha256:c21eb2b7fe3b93998379f7b13ad7d23b3ef06ab821b008c6b12b954acfb587ec"},
{file = "langgraph_api-0.0.32-py3-none-any.whl", hash = "sha256:7990cedc65f784813aba867c5bde3fdfae3fa4588baef1aa346cbeac7c3aebf1"},
{file = "langgraph_api-0.0.32.tar.gz", hash = "sha256:6f5b698ad8d136b73c2c53bcfa30670e9244a318b08b5e9cf00a707ea57c058c"},
]
[package.dependencies]
cryptography = ">=43.0.3,<44.0.0"
httpx = ">=0.25.0"
jsonschema-rs = ">=0.20.0,<0.21.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.15,<3.0"
langgraph-sdk = ">=0.1.53,<0.2.0"
langgraph-checkpoint = ">=2.0.21,<3.0"
langgraph-sdk = ">=0.1.58,<0.2.0"
langsmith = ">=0.1.63,<0.4.0"
orjson = ">=3.9.7"
pyjwt = ">=2.9.0,<3.0.0"
sse-starlette = ">=2.1.0,<2.2.0"
starlette = ">=0.38.6"
structlog = ">=23.1.0,<24.0.0"
structlog = ">=24.1.0,<26"
tenacity = ">=8.0.0"
uvicorn = ">=0.26.0"
watchfiles = ">=0.13"
[[package]]
name = "langgraph-checkpoint"
version = "2.0.16"
version = "2.0.21"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = true
python-versions = "<4.0.0,>=3.9.0"
files = [
{file = "langgraph_checkpoint-2.0.16-py3-none-any.whl", hash = "sha256:dfab51076a6eddb5f9e146cfe1b977e3dd6419168b2afa23ff3f4e47973bf06f"},
{file = "langgraph_checkpoint-2.0.16.tar.gz", hash = "sha256:49ba8cfa12b2aae845ccc3b1fbd1d7a8d3a6c4a2e387ab3a92fca40dd3d4baa5"},
{file = "langgraph_checkpoint-2.0.21-py3-none-any.whl", hash = "sha256:ca89c2090cd9729f83f9782226935dc5ff9fe7756c24936f484ccb0ce367f87b"},
{file = "langgraph_checkpoint-2.0.21.tar.gz", hash = "sha256:52beeb6dc1bd8c487b8315466cab271093b65eb97f54a0942dfe105cd20b237f"},
]
[package.dependencies]
@@ -594,13 +594,13 @@ langgraph-checkpoint = ">=2.0.10,<3.0.0"
[[package]]
name = "langgraph-sdk"
version = "0.1.53"
version = "0.1.58"
description = "SDK for interacting with LangGraph API"
optional = true
python-versions = "<4.0.0,>=3.9.0"
files = [
{file = "langgraph_sdk-0.1.53-py3-none-any.whl", hash = "sha256:4fab62caad73661ffe4c3ababedcd0d7bfaaba986bee4416b9c28948458a3af5"},
{file = "langgraph_sdk-0.1.53.tar.gz", hash = "sha256:12906ed965905fa27e0c28d9fa07dc6fd89e6895ff321ff049fdf3965d057cc4"},
{file = "langgraph_sdk-0.1.58-py3-none-any.whl", hash = "sha256:65f88cf5582da0c316714dc475126fa03c5f74d72bc0b9221dd42649de8e23d4"},
{file = "langgraph_sdk-0.1.58.tar.gz", hash = "sha256:ef8b0e4c08af8c7efd3919497879c87a3627806b51e4ba5e8b06e0717e3d44cd"},
]
[package.dependencies]
@@ -1357,18 +1357,18 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart
[[package]]
name = "structlog"
version = "23.3.0"
version = "25.2.0"
description = "Structured Logging for Python"
optional = true
python-versions = ">=3.8"
files = [
{file = "structlog-23.3.0-py3-none-any.whl", hash = "sha256:d6922a88ceabef5b13b9eda9c4043624924f60edbb00397f4d193bd754cde60a"},
{file = "structlog-23.3.0.tar.gz", hash = "sha256:24b42b914ac6bc4a4e6f716e82ac70d7fb1e8c3b1035a765591953bfc37101a5"},
{file = "structlog-25.2.0-py3-none-any.whl", hash = "sha256:0fecea2e345d5d491b72f3db2e5fcd6393abfc8cd06a4851f21fcd4d1a99f437"},
{file = "structlog-25.2.0.tar.gz", hash = "sha256:d9f9776944207d1035b8b26072b9b140c63702fd7aa57c2f85d28ab701bd8e92"},
]
[package.extras]
dev = ["structlog[tests,typing]"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"]
dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"]
docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"]
tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"]
typing = ["mypy (>=1.4)", "rich", "twisted"]
@@ -1717,4 +1717,4 @@ inmem = ["langgraph-api", "python-dotenv"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0,<4.0"
content-hash = "d0e2bdcb600ad031867413025fcc58bb162609209359d63ca99a77060cf8cbb4"
content-hash = "f5aa4d66f9c0b98b8321a70a82387dc6e5f3a3a7ecedd87ac00d6415199038f9"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.1.76"
version = "0.1.79"
description = "CLI for interacting with LangGraph API"
authors = []
license = "MIT"
@@ -14,7 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
click = "^8.1.7"
langgraph-api = { version = ">=0.0.27,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-api = { version = ">=0.0.32,<0.1.0", optional = true, python = ">=3.11,<4.0" }
python-dotenv = { version = ">=0.8.0", optional = true }
[tool.poetry.group.dev.dependencies]
+10
View File
@@ -459,6 +459,16 @@
},
"refresh_on_read": {
"type": "boolean"
},
"sweep_interval_minutes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
]
}
},
"required": []
+10
View File
@@ -459,6 +459,16 @@
},
"refresh_on_read": {
"type": "boolean"
},
"sweep_interval_minutes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
]
}
},
"required": []
+3 -1
View File
@@ -58,9 +58,11 @@ WORKERS ?= auto
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
MAXFAIL ?=
MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
# Add an '-x' if xdist is enabled
XDIST_ARGS := $(if $(WORKERS),-x $(XDIST_ARGS),)
test_watch:
make start-postgres && poetry run ptw . -- --ff -vv -x $(XDIST_ARGS) $(MAXFAIL_ARGS) --snapshot-update --tb short $(TEST); \
make start-postgres && poetry run ptw . -- --ff -vv $(XDIST_ARGS) $(MAXFAIL_ARGS) --snapshot-update --tb short $(TEST); \
EXIT_CODE=$$?; \
make stop-postgres; \
exit $$EXIT_CODE
+46 -298
View File
@@ -1,339 +1,87 @@
# 🦜🕸️LangGraph
<picture class="github-only">
<source media="(prefers-color-scheme: light)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_light.svg">
<img alt="LangGraph Logo" src="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg" width="80%">
</picture>
<div>
<br>
</div>
[![Version](https://img.shields.io/pypi/v/langgraph.svg)](https://pypi.org/project/langgraph/)
[![Downloads](https://static.pepy.tech/badge/langgraph/month)](https://pepy.tech/project/langgraph)
[![Open Issues](https://img.shields.io/github/issues-raw/langchain-ai/langgraph)](https://github.com/langchain-ai/langgraph/issues)
[![Docs](https://img.shields.io/badge/docs-latest-blue)](https://langchain-ai.github.io/langgraph/)
⚡ Building language agents as graphs ⚡
> [!NOTE]
> Looking for the JS version? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://langchain-ai.github.io/langgraphjs/).
## Overview
LangGraph — used by Replit, Uber, LinkedIn, GitLab and more — is a low-level orchestration framework for building controllable agents. While langchain provides integrations and composable components to streamline LLM application development, the LangGraph library enables agent orchestration — offering customizable architectures, long-term memory, and human-in-the-loop to reliably handle complex tasks.
[LangGraph](https://langchain-ai.github.io/langgraph/) is a library for building
stateful, multi-actor applications with LLMs, used to create agent and multi-agent
workflows. Check out an introductory tutorial [here](https://langchain-ai.github.io/langgraph/tutorials/introduction/).
LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
### Why use LangGraph?
LangGraph powers [production-grade agents](https://www.langchain.com/built-with-langgraph), trusted by Linkedin, Uber, Klarna, GitLab, and many more. LangGraph provides fine-grained control over both the flow and state of your agent applications. It implements a central [persistence layer](https://langchain-ai.github.io/langgraph/concepts/persistence/), enabling features that are common to most agent architectures:
- **Memory**: LangGraph persists arbitrary aspects of your application's state,
supporting memory of conversations and other updates within and across user
interactions;
- **Human-in-the-loop**: Because state is checkpointed, execution can be interrupted
and resumed, allowing for decisions, validation, and corrections at key stages via
human input.
Standardizing these components allows individuals and teams to focus on the behavior
of their agent, instead of its supporting infrastructure.
Through [LangGraph Platform](#langgraph-platform), LangGraph also provides tooling for
the development, deployment, debugging, and monitoring of your applications.
LangGraph integrates seamlessly with
[LangChain](https://python.langchain.com/docs/introduction/) and
[LangSmith](https://docs.smith.langchain.com/) (but does not require them).
To learn more about LangGraph, check out our first LangChain Academy
course, *Introduction to LangGraph*, available for free
[here](https://academy.langchain.com/courses/intro-to-langgraph).
### LangGraph Platform
[LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform) is infrastructure for deploying LangGraph agents. It is a commercial solution for deploying agentic applications to production, built on the open-source LangGraph framework. The LangGraph Platform consists of several components that work together to support the development, deployment, debugging, and monitoring of LangGraph applications: [LangGraph Server](https://langchain-ai.github.io/langgraph/concepts/langgraph_server) (APIs), [LangGraph SDKs](https://langchain-ai.github.io/langgraph/concepts/sdk) (clients for the APIs), [LangGraph CLI](https://langchain-ai.github.io/langgraph/concepts/langgraph_cli) (command line tool for building the server), and [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio) (UI/debugger).
See deployment options [here](https://langchain-ai.github.io/langgraph/concepts/deployment_options/)
(includes a free tier).
Here are some common issues that arise in complex deployments, which LangGraph Platform addresses:
- **Streaming support**: LangGraph Server provides [multiple streaming modes](https://langchain-ai.github.io/langgraph/concepts/streaming) optimized for various application needs
- **Background runs**: Runs agents asynchronously in the background
- **Support for long running agents**: Infrastructure that can handle long running processes
- **[Double texting](https://langchain-ai.github.io/langgraph/concepts/double_texting)**: Handle the case where you get two messages from the user before the agent can respond
- **Handle burstiness**: Task queue for ensuring requests are handled consistently without loss, even under heavy loads
## Installation
```shell
```bash
pip install -U langgraph
```
## Example
Let's build a tool-calling [ReAct-style](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#react-implementation) agent that uses a search tool!
```shell
pip install langchain-anthropic
```
```shell
export ANTHROPIC_API_KEY=sk-...
```
Optionally, we can set up [LangSmith](https://docs.smith.langchain.com/) for best-in-class observability.
```shell
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=lsv2_sk_...
```
The simplest way to create a tool-calling agent in LangGraph is to use `create_react_agent`:
<details open>
<summary>High-level implementation</summary>
To learn more about how to use LangGraph, check out [the docs](https://langchain-ai.github.io/langgraph/). We show a simple example below of how to create a ReAct agent.
```python
# This code depends on pip install langchain[anthropic]
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.memory import MemorySaver
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
# Define the tools for the agent to use
@tool
def search(query: str):
"""Call to surf the web."""
# This is a placeholder, but don't tell the LLM that...
if "sf" in query.lower() or "san francisco" in query.lower():
return "It's 60 degrees and foggy."
return "It's 90 degrees and sunny."
tools = [search]
model = ChatAnthropic(model="claude-3-5-sonnet-latest", temperature=0)
# Initialize memory to persist state between graph runs
checkpointer = MemorySaver()
app = create_react_agent(model, tools, checkpointer=checkpointer)
# Use the agent
final_state = app.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
config={"configurable": {"thread_id": 42}}
agent = create_react_agent("anthropic:claude-3-7-sonnet-latest", tools=[search])
agent.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]}
)
final_state["messages"][-1].content
```
```
"Based on the search results, I can tell you that the current weather in San Francisco is:\n\nTemperature: 60 degrees Fahrenheit\nConditions: Foggy\n\nSan Francisco is known for its microclimates and frequent fog, especially during the summer months. The temperature of 60°F (about 15.5°C) is quite typical for the city, which tends to have mild temperatures year-round. The fog, often referred to as "Karl the Fog" by locals, is a characteristic feature of San Francisco\'s weather, particularly in the mornings and evenings.\n\nIs there anything else you\'d like to know about the weather in San Francisco or any other location?"
```
Now when we pass the same <code>"thread_id"</code>, the conversation context is retained via the saved state (i.e. stored list of messages)
## Why use LangGraph?
```python
final_state = app.invoke(
{"messages": [{"role": "user", "content": "what about ny"}]},
config={"configurable": {"thread_id": 42}}
)
final_state["messages"][-1].content
```
LangGraph is built for developers who want to build powerful, adaptable AI agents. Developers choose LangGraph for:
```
"Based on the search results, I can tell you that the current weather in New York City is:\n\nTemperature: 90 degrees Fahrenheit (approximately 32.2 degrees Celsius)\nConditions: Sunny\n\nThis weather is quite different from what we just saw in San Francisco. New York is experiencing much warmer temperatures right now. Here are a few points to note:\n\n1. The temperature of 90°F is quite hot, typical of summer weather in New York City.\n2. The sunny conditions suggest clear skies, which is great for outdoor activities but also means it might feel even hotter due to direct sunlight.\n3. This kind of weather in New York often comes with high humidity, which can make it feel even warmer than the actual temperature suggests.\n\nIt's interesting to see the stark contrast between San Francisco's mild, foggy weather and New York's hot, sunny conditions. This difference illustrates how varied weather can be across different parts of the United States, even on the same day.\n\nIs there anything else you'd like to know about the weather in New York or any other location?"
```
</details>
- **Reliability and controllability.** Steer agent actions with moderation checks and human-in-the-loop approvals. LangGraph persists context for long-running workflows, keeping your agents on course.
- **Low-level and extensible.** Build custom agents with fully descriptive, low-level primitives free from rigid abstractions that limit customization. Design scalable multi-agent systems, with each agent serving a specific role tailored to your use case.
- **First-class streaming support.** With token-by-token streaming and streaming of intermediate steps, LangGraph gives users clear visibility into agent reasoning and actions as they unfold in real time.
> [!TIP]
> LangGraph is a **low-level** framework that allows you to implement any custom agent
architectures. Click on the low-level implementation below to see how to implement a
tool-calling agent from scratch.
LangGraph is trusted in production and powering agents for companies like:
<details>
<summary>Low-level implementation</summary>
- [Klarna](https://blog.langchain.dev/customers-klarna/): Customer support bot for 85 million active users
- [Elastic](https://www.elastic.co/blog/elastic-security-generative-ai-features): Security AI assistant for threat detection
- [Uber](https://dpe.org/sessions/ty-smith-adam-huda/this-year-in-ubers-ai-driven-developer-productivity-revolution/): Automated unit test generation
- [Replit](https://www.langchain.com/breakoutagents/replit): Code generation
- And many more ([see list here](https://www.langchain.com/built-with-langgraph))
```python
from typing import Literal
## LangGraphs ecosystem
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph, MessagesState
from langgraph.prebuilt import ToolNode
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
# Define the tools for the agent to use
@tool
def search(query: str):
"""Call to surf the web."""
# This is a placeholder, but don't tell the LLM that...
if "sf" in query.lower() or "san francisco" in query.lower():
return "It's 60 degrees and foggy."
return "It's 90 degrees and sunny."
## Pairing with LangGraph Platform
While LangGraph is our open-source agent orchestration framework, enterprises that need scalable agent deployment can benefit from [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/).
tools = [search]
LangGraph Platform can help engineering teams:
tool_node = ToolNode(tools)
- **Accelerate agent development**: Quickly create agent UXs with configurable templates and [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/) for visualizing and debugging agent interactions.
- **Deploy seamlessly**: We handle the complexity of deploying your agent. LangGraph Platform includes robust APIs for memory, threads, and cron jobs plus auto-scaling task queues & servers.
- **Centralize agent management & reusability**: Discover, reuse, and manage agents across the organization. Business users can also modify agents without coding.
model = ChatAnthropic(model="claude-3-5-sonnet-latest", temperature=0).bind_tools(tools)
## Additional resources
# Define the function that determines whether to continue or not
def should_continue(state: MessagesState) -> Literal["tools", END]:
messages = state['messages']
last_message = messages[-1]
# If the LLM makes a tool call, then we route to the "tools" node
if last_message.tool_calls:
return "tools"
# Otherwise, we stop (reply to the user)
return END
- [LangChain Academy](https://academy.langchain.com/courses/intro-to-langgraph): Learn the basics of LangGraph in our free, structured course.
- [Tutorials](https://langchain-ai.github.io/langgraph/tutorials/): Simple walkthroughs with guided examples on getting started with LangGraph.
- [Templates](https://langchain-ai.github.io/langgraph/concepts/template_applications/): Pre-built reference apps for common agentic workflows (e.g. ReAct agent, memory, retrieval etc.) that can be cloned and adapted.
- [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
- [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
- [Built with LangGraph](https://www.langchain.com/built-with-langgraph): Hear how industry leaders use LangGraph to ship powerful, production-ready AI applications.
## Acknowledgements
# Define the function that calls the model
def call_model(state: MessagesState):
messages = state['messages']
response = model.invoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
# Define a new graph
workflow = StateGraph(MessagesState)
# Define the two nodes we will cycle between
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
# Set the entrypoint as `agent`
# This means that this node is the first one called
workflow.add_edge(START, "agent")
# We now add a conditional edge
workflow.add_conditional_edges(
# First, we define the start node. We use `agent`.
# This means these are the edges taken after the `agent` node is called.
"agent",
# Next, we pass in the function that will determine which node is called next.
should_continue,
)
# We now add a normal edge from `tools` to `agent`.
# This means that after `tools` is called, `agent` node is called next.
workflow.add_edge("tools", 'agent')
# Initialize memory to persist state between graph runs
checkpointer = MemorySaver()
# Finally, we compile it!
# This compiles it into a LangChain Runnable,
# meaning you can use it as you would any other runnable.
# Note that we're (optionally) passing the memory when compiling the graph
app = workflow.compile(checkpointer=checkpointer)
# Use the agent
final_state = app.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
config={"configurable": {"thread_id": 42}}
)
final_state["messages"][-1].content
```
<b>Step-by-step Breakdown</b>:
<details>
<summary>Initialize the model and tools.</summary>
<ul>
<li>
We use <code>ChatAnthropic</code> as our LLM. <strong>NOTE:</strong> we need to make sure the model knows that it has these tools available to call. We can do this by converting the LangChain tools into the format for OpenAI tool calling using the <code>.bind_tools()</code> method.
</li>
<li>
We define the tools we want to use - a search tool in our case. It is really easy to create your own tools - see documentation here on how to do that <a href="https://python.langchain.com/docs/how_to/custom_tools/">here</a>.
</li>
</ul>
</details>
<details>
<summary>Initialize graph with state.</summary>
<ul>
<li>We initialize graph (<code>StateGraph</code>) by passing state schema (in our case <code>MessagesState</code>)</li>
<li><code>MessagesState</code> is a prebuilt state schema that has one attribute -- a list of LangChain <code>Message</code> objects, as well as logic for merging the updates from each node into the state.</li>
</ul>
</details>
<details>
<summary>Define graph nodes.</summary>
There are two main nodes we need:
<ul>
<li>The <code>agent</code> node: responsible for deciding what (if any) actions to take.</li>
<li>The <code>tools</code> node that invokes tools: if the agent decides to take an action, this node will then execute that action.</li>
</ul>
</details>
<details>
<summary>Define entry point and graph edges.</summary>
First, we need to set the entry point for graph execution - <code>agent</code> node.
Then we define one normal and one conditional edge. Conditional edge means that the destination depends on the contents of the graph's state (<code>MessagesState</code>). In our case, the destination is not known until the agent (LLM) decides.
<ul>
<li>Conditional edge: after the agent is called, we should either:
<ul>
<li>a. Run tools if the agent said to take an action, OR</li>
<li>b. Finish (respond to the user) if the agent did not ask to run tools</li>
</ul>
</li>
<li>Normal edge: after the tools are invoked, the graph should always return to the agent to decide what to do next</li>
</ul>
</details>
<details>
<summary>Compile the graph.</summary>
<ul>
<li>
When we compile the graph, we turn it into a LangChain
<a href="https://python.langchain.com/docs/concepts/runnables/">Runnable</a>,
which automatically enables calling <code>.invoke()</code>, <code>.stream()</code> and <code>.batch()</code>
with your inputs
</li>
<li>
We can also optionally pass checkpointer object for persisting state between graph runs, and enabling memory,
human-in-the-loop workflows, time travel and more. In our case we use <code>MemorySaver</code> -
a simple in-memory checkpointer
</li>
</ul>
</details>
<details>
<summary>Execute the graph.</summary>
<ol>
<li>LangGraph adds the input message to the internal state, then passes the state to the entrypoint node, <code>"agent"</code>.</li>
<li>The <code>"agent"</code> node executes, invoking the chat model.</li>
<li>The chat model returns an <code>AIMessage</code>. LangGraph adds this to the state.</li>
<li>Graph cycles the following steps until there are no more <code>tool_calls</code> on <code>AIMessage</code>:
<ul>
<li>If <code>AIMessage</code> has <code>tool_calls</code>, <code>"tools"</code> node executes</li>
<li>The <code>"agent"</code> node executes again and returns <code>AIMessage</code></li>
</ul>
</li>
<li>Execution progresses to the special <code>END</code> value and outputs the final state. And as a result, we get a list of all our chat messages as output.</li>
</ol>
</details>
</details>
## Documentation
* [Tutorials](https://langchain-ai.github.io/langgraph/tutorials/): Learn to build with LangGraph through guided examples.
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
* [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform): LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework.
## Resources
* [Built with LangGraph](https://www.langchain.com/built-with-langgraph): Hear how industry leaders use LangGraph to ship powerful, production-ready AI applications.
## Contributing
For more information on how to contribute, see [here](https://github.com/langchain-ai/langgraph/blob/main/CONTRIBUTING.md).
LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
+164
View File
@@ -6,9 +6,12 @@ from pyperf._runner import Runner
from uvloop import new_event_loop
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_state import wide_state
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph
from langgraph.pregel import Pregel
@@ -42,6 +45,11 @@ def run(graph: Pregel, input: dict):
)
def compile_graph(graph: StateGraph) -> None:
"""Compile the graph."""
graph.compile()
benchmarks = (
(
"fanout_to_subgraph_10x",
@@ -203,12 +211,168 @@ benchmarks = (
]
},
),
(
"sequential_20",
create_sequential(20).compile(),
create_sequential(20).compile(),
{"messages": []}, # Empty list of messages
),
(
"sequential_50",
create_sequential(50).compile(),
create_sequential(50).compile(),
{"messages": []}, # Empty list of messages
),
(
"sequential_100",
create_sequential(100).compile(),
create_sequential(100).compile(),
{"messages": []}, # Empty list of messages
),
(
"sequential_200",
create_sequential(200).compile(),
create_sequential(200).compile(),
{"messages": []}, # Empty list of messages
),
(
"pydantic_state_25x300",
pydantic_state(300).compile(checkpointer=None),
pydantic_state(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)
}
]
},
),
(
"pydantic_state_25x300_checkpoint",
pydantic_state(300).compile(checkpointer=MemorySaver()),
pydantic_state(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)
}
]
},
),
(
"pydantic_state_15x600",
pydantic_state(600).compile(checkpointer=None),
pydantic_state(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)
}
]
},
),
(
"pydantic_state_15x600_checkpoint",
pydantic_state(600).compile(checkpointer=MemorySaver()),
pydantic_state(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)
}
]
},
),
(
"pydantic_state_9x1200",
pydantic_state(1200).compile(checkpointer=None),
pydantic_state(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)
}
]
},
),
(
"pydantic_state_9x1200_checkpoint",
pydantic_state(1200).compile(checkpointer=MemorySaver()),
pydantic_state(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)
}
]
},
),
)
r = Runner()
# Full graph run time
for name, agraph, graph, input in benchmarks:
r.bench_async_func(name, arun, agraph, input, loop_factory=new_event_loop)
if graph is not None:
r.bench_func(name + "_sync", run, graph, input)
# Graph compilation times
compilation_benchmarks = (
(
"sequential_1000",
create_sequential(1_000),
),
(
"sequential_10000",
create_sequential(10_000),
),
(
"pydantic_state_25x300",
pydantic_state(300),
),
(
"pydantic_state_15x600",
pydantic_state(600),
),
(
"pydantic_state_9x1200",
pydantic_state(1200),
),
(
"wide_state_15x600",
wide_state(600),
),
(
"wide_state_9x1200",
wide_state(1200),
),
)
for name, graph in compilation_benchmarks:
r.bench_func(name + "_compilation", compile_graph, graph)
+327
View File
@@ -0,0 +1,327 @@
import operator
from functools import partial
from random import choice
from typing import Annotated, Optional, Sequence
from pydantic import BaseModel, Field, field_validator
from langgraph.constants import END, START
from langgraph.graph.state import StateGraph
def pydantic_state(n: int) -> StateGraph:
class State(BaseModel):
messages: Annotated[list, operator.add] = Field(default_factory=list)
@field_validator("messages", mode="after")
@classmethod
def validate_messages(cls, v):
if not isinstance(v, list):
raise TypeError("messages must be a list")
for msg in v:
if not isinstance(msg, dict):
raise TypeError("messages must be a list of dicts")
if not all(isinstance(k, str) for k in msg.keys()):
raise TypeError("messages must be a list of dicts with str keys")
return v
trigger_events: Annotated[list, operator.add] = Field(default_factory=list)
"""The external events that are converted by the graph."""
@field_validator("trigger_events", mode="after")
@classmethod
def validate_trigger_events(cls, v):
if not isinstance(v, list):
raise TypeError("trigger_events must be a list")
for event in v:
if not isinstance(event, dict):
raise TypeError("trigger_events must be a list of dicts")
if not all(isinstance(k, str) for k in event.keys()):
raise TypeError(
"trigger_events must be a list of dicts with str keys"
)
return v
primary_issue_medium: Annotated[str, lambda x, y: y or x] = Field(
default="email"
)
"""The primary issue medium for the current conversation."""
@field_validator("primary_issue_medium", mode="after")
@classmethod
def validate_primary_issue_medium(cls, v):
if not isinstance(v, str):
raise TypeError("primary_issue_medium must be a string")
return v
autoresponse: Annotated[Optional[dict], lambda _, y: y] = Field(
default=None
) # Always overwrite
@field_validator("autoresponse", mode="after")
@classmethod
def validate_autoresponse(cls, v):
if v is not None and not isinstance(v, dict):
raise TypeError("autoresponse must be a dict or None")
return v
issue: Annotated[dict | None, lambda x, y: y if y else x] = Field(default=None)
@field_validator("issue", mode="after")
@classmethod
def validate_issue(cls, v):
if v is not None and not isinstance(v, dict):
raise TypeError("issue must be a dict or None")
return v
relevant_rules: Optional[list[dict]] = Field(default=None)
"""SOPs fetched from the rulebook that are relevant to the current conversation."""
@field_validator("relevant_rules", mode="after")
@classmethod
def validate_relevant_rules(cls, v):
if v is None:
return v
if not isinstance(v, list):
raise TypeError("relevant_rules must be a list or None")
for rule in v:
if not isinstance(rule, dict):
raise TypeError("relevant_rules must be a list of dicts")
if not all(isinstance(k, str) for k in rule.keys()):
raise TypeError(
"relevant_rules must be a list of dicts with str keys"
)
return v
memory_docs: Optional[list[dict]] = Field(default=None)
"""Memory docs fetched from the memory service that are relevant to the current conversation."""
@field_validator("memory_docs", mode="after")
@classmethod
def validate_memory_docs(cls, v):
if v is None:
return v
if not isinstance(v, list):
raise TypeError("memory_docs must be a list or None")
for doc in v:
if not isinstance(doc, dict):
raise TypeError("memory_docs must be a list of dicts")
if not all(isinstance(k, str) for k in doc.keys()):
raise TypeError("memory_docs must be a list of dicts with str keys")
return v
categorizations: Annotated[list[dict], operator.add] = Field(
default_factory=list
)
"""The issue categorizations auto-generated by the AI."""
@field_validator("categorizations", mode="after")
@classmethod
def validate_categorizations(cls, v):
if not isinstance(v, list):
raise TypeError("categorizations must be a list")
for categorization in v:
if not isinstance(categorization, dict):
raise TypeError("categorizations must be a list of dicts")
if not all(isinstance(k, str) for k in categorization.keys()):
raise TypeError(
"categorizations must be a list of dicts with str keys"
)
return v
responses: Annotated[list[dict], operator.add] = Field(default_factory=list)
"""The draft responses recommended by the AI."""
@field_validator("responses", mode="after")
@classmethod
def validate_responses(cls, v):
if not isinstance(v, list):
raise TypeError("responses must be a list")
for response in v:
if not isinstance(response, dict):
raise TypeError("responses must be a list of dicts")
if not all(isinstance(k, str) for k in response.keys()):
raise TypeError("responses must be a list of dicts with str keys")
return v
user_info: Annotated[Optional[dict], lambda x, y: y if y is not None else x] = (
Field(default=None)
)
"""The current user state (by email)."""
@field_validator("user_info", mode="after")
@classmethod
def validate_user_info(cls, v):
if v is not None and not isinstance(v, dict):
raise TypeError("user_info must be a dict or None")
return v
crm_info: Annotated[Optional[dict], lambda x, y: y if y is not None else x] = (
Field(default=None)
)
"""The CRM information for organization the current user is from."""
@field_validator("crm_info", mode="after")
@classmethod
def validate_crm_info(cls, v):
if v is not None and not isinstance(v, dict):
raise TypeError("crm_info must be a dict or None")
return v
email_thread_id: Annotated[
Optional[str], lambda x, y: y if y is not None else x
] = Field(default=None)
"""The current email thread ID."""
@field_validator("email_thread_id", mode="after")
@classmethod
def validate_email_thread_id(cls, v):
if v is not None and not isinstance(v, str):
raise TypeError("email_thread_id must be a string or None")
return v
slack_participants: Annotated[dict, operator.or_] = Field(default_factory=dict)
"""The growing list of current slack participants."""
@field_validator("slack_participants", mode="after")
@classmethod
def validate_slack_participants(cls, v):
if not isinstance(v, dict):
raise TypeError("slack_participants must be a dict")
for participant in v:
if not isinstance(participant, str):
raise TypeError("slack_participants must be a dict with str keys")
return v
bot_id: Optional[str] = Field(default=None)
"""The ID of the bot user in the slack channel."""
@field_validator("bot_id", mode="after")
@classmethod
def validate_bot_id(cls, v):
if v is not None and not isinstance(v, str):
raise TypeError("bot_id must be a string or None")
return v
notified_assignees: Annotated[dict, operator.or_] = Field(default_factory=dict)
@field_validator("notified_assignees", mode="after")
def validate_notified_assignees(cls, v):
if not isinstance(v, dict):
raise TypeError("notified_assignees must be a dict")
for assignee in v:
if not isinstance(assignee, str):
raise TypeError("notified_assignees must be a dict with str keys")
return v
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 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 = pydantic_state(1000).compile(checkpointer=MemorySaver())
input = {
"messages": [
{
str(i) * 10: {
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
for j in range(5)
}
for i in range(5)
}
]
}
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())
+48
View File
@@ -0,0 +1,48 @@
"""Create a sequential no-op graph consisting of a few hundred nodes."""
from langgraph.graph import MessagesState, StateGraph
from langgraph.utils.runnable import RunnableCallable
def create_sequential(number_nodes) -> StateGraph:
"""Create a sequential no-op graph consisting of a few hundred nodes."""
builder = StateGraph(MessagesState)
def noop(state: MessagesState) -> None:
"""No-op function."""
pass
async def anoop(state: MessagesState) -> None:
"""No-op function."""
pass
prev_node = "__start__"
for i in range(number_nodes):
name = f"node_{i}"
builder.add_node(name, RunnableCallable(noop, anoop))
builder.add_edge(prev_node, name)
prev_node = name
builder.add_edge(prev_node, "__end__")
return builder
if __name__ == "__main__":
import asyncio
import time
import uvloop
graph = create_sequential(2000).compile()
input = {"messages": []} # Empty list of messages
config = {"recursion_limit": 20000000000}
async def run():
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")
+15 -8
View File
@@ -1,8 +1,9 @@
from typing import Generic, Optional, Sequence, Type
from typing import Any, Generic, Optional, Sequence, Type
from typing_extensions import Self
from langgraph.channels.base import BaseChannel, Value
from langgraph.constants import MISSING
from langgraph.errors import EmptyChannelError
@@ -12,6 +13,10 @@ class AnyValue(Generic[Value], BaseChannel[Value, Value, Value]):
__slots__ = ("typ", "value")
def __init__(self, typ: Any, key: str = "") -> None:
super().__init__(typ, key)
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, AnyValue)
@@ -34,17 +39,19 @@ class AnyValue(Generic[Value], BaseChannel[Value, Value, Value]):
def update(self, values: Sequence[Value]) -> bool:
if len(values) == 0:
try:
del self.value
return True
except AttributeError:
if self.value is MISSING:
return False
else:
self.value = MISSING
return True
self.value = values[-1]
return True
def get(self) -> Value:
try:
return self.value
except AttributeError:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
+11
View File
@@ -64,6 +64,17 @@ class BaseChannel(Generic[Value, Update, C], ABC):
"""
return False
def is_available(self) -> bool:
"""Return True if the channel is available (not empty), False otherwise.
Subclasses should override this method to provide a more efficient
implementation than calling get() and catching EmptyChannelError.
"""
try:
self.get()
return True
except EmptyChannelError:
return False
__all__ = [
"BaseChannel",
+8 -5
View File
@@ -10,6 +10,7 @@ from typing import (
from typing_extensions import NotRequired, Required, Self
from langgraph.channels.base import BaseChannel, Value
from langgraph.constants import MISSING
from langgraph.errors import EmptyChannelError
@@ -51,7 +52,7 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
try:
self.value = typ()
except Exception:
pass
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, BinaryOperatorAggregate) and (
@@ -81,7 +82,7 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
def update(self, values: Sequence[Value]) -> bool:
if not values:
return False
if not hasattr(self, "value"):
if self.value is MISSING:
self.value = values[0]
values = values[1:]
for value in values:
@@ -89,7 +90,9 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
return True
def get(self) -> Value:
try:
return self.value
except AttributeError:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
@@ -85,6 +85,9 @@ class DynamicBarrierValue(
raise EmptyChannelError()
return None
def is_available(self) -> bool:
return self.seen == self.names
def consume(self) -> bool:
if self.seen == self.names:
self.seen = set()
@@ -3,6 +3,7 @@ from typing import Any, Generic, Optional, Sequence, Type
from typing_extensions import Self
from langgraph.channels.base import BaseChannel, Value
from langgraph.constants import MISSING
from langgraph.errors import EmptyChannelError, InvalidUpdateError
@@ -14,6 +15,7 @@ class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
def __init__(self, typ: Any, guard: bool = True) -> None:
super().__init__(typ)
self.guard = guard
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, EphemeralValue) and value.guard == self.guard
@@ -37,10 +39,10 @@ class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
def update(self, values: Sequence[Value]) -> bool:
if len(values) == 0:
try:
del self.value
if self.value is not MISSING:
self.value = MISSING
return True
except AttributeError:
else:
return False
if len(values) != 1 and self.guard:
raise InvalidUpdateError(
@@ -51,7 +53,9 @@ class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
return True
def get(self) -> Value:
try:
return self.value
except AttributeError:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
@@ -1,8 +1,9 @@
from typing import Generic, Optional, Sequence, Type
from typing import Any, Generic, Optional, Sequence, Type
from typing_extensions import Self
from langgraph.channels.base import BaseChannel, Value
from langgraph.constants import MISSING
from langgraph.errors import (
EmptyChannelError,
ErrorCode,
@@ -16,6 +17,10 @@ class LastValue(Generic[Value], BaseChannel[Value, Value, Value]):
__slots__ = ("value",)
def __init__(self, typ: Any, key: str = "") -> None:
super().__init__(typ, key)
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, LastValue)
@@ -50,7 +55,9 @@ class LastValue(Generic[Value], BaseChannel[Value, Value, Value]):
return True
def get(self) -> Value:
try:
return self.value
except AttributeError:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
@@ -60,6 +60,9 @@ class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]):
raise EmptyChannelError()
return None
def is_available(self) -> bool:
return self.seen == self.names
def consume(self) -> bool:
if self.seen == self.names:
self.seen = set()
@@ -75,3 +75,6 @@ class Topic(
return list(self.values)
else:
raise EmptyChannelError
def is_available(self) -> bool:
return bool(self.values)
@@ -3,6 +3,7 @@ from typing import Generic, Optional, Sequence, Type
from typing_extensions import Self
from langgraph.channels.base import BaseChannel, Value
from langgraph.constants import MISSING
from langgraph.errors import EmptyChannelError, InvalidUpdateError
@@ -14,6 +15,7 @@ class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]):
def __init__(self, typ: Type[Value], guard: bool = True) -> None:
super().__init__(typ)
self.guard = guard
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, UntrackedValue) and value.guard == self.guard
@@ -48,7 +50,9 @@ class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]):
return True
def get(self) -> Value:
try:
return self.value
except AttributeError:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
+1
View File
@@ -138,6 +138,7 @@ class Branch(NamedTuple):
reader=reader,
name=None,
trace=False,
func_accepts_config=True,
)
)
+61 -14
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
from collections import defaultdict
from typing import (
@@ -31,6 +32,7 @@ from langgraph.constants import (
)
from langgraph.graph.branch import Branch
from langgraph.pregel import Channel, Pregel
from langgraph.pregel.protocol import PregelProtocol
from langgraph.pregel.read import PregelNode
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
from langgraph.types import All, Checkpointer
@@ -418,7 +420,38 @@ class CompiledGraph(Pregel):
*,
xray: Union[int, bool] = False,
) -> DrawableGraph:
return self.get_graph(config, xray=xray)
"""Returns a drawable representation of the computation graph."""
from langgraph.pregel.remote import RemoteGraph
# gather subgraphs
if xray:
subpregels: dict[str, PregelProtocol] = {
k: v
async for k, v in self.aget_subgraphs()
if isinstance(v, (CompiledGraph, RemoteGraph))
}
subgraphs = {
k: v
for k, v in zip(
subpregels,
await asyncio.gather(
*(
p.aget_graph(
config,
xray=xray
if isinstance(xray, bool) or xray <= 0
else xray - 1,
)
for p in subpregels.values()
)
),
)
}
else:
subgraphs = {}
# draw the graph
return self._draw_graph(config, subgraphs=subgraphs)
def get_graph(
self,
@@ -427,17 +460,36 @@ class CompiledGraph(Pregel):
xray: Union[int, bool] = False,
) -> DrawableGraph:
"""Returns a drawable representation of the computation graph."""
from langgraph.pregel.remote import RemoteGraph
# gather subgraphs
if xray:
subgraphs = {
k: v.get_graph(
config,
xray=xray if isinstance(xray, bool) or xray <= 0 else xray - 1,
)
for k, v in self.get_subgraphs()
if isinstance(v, (CompiledGraph, RemoteGraph))
}
else:
subgraphs = {}
# draw the graph
return self._draw_graph(config, subgraphs=subgraphs)
def _draw_graph(
self,
config: Optional[RunnableConfig] = None,
*,
subgraphs: dict[str, DrawableGraph] = {},
) -> DrawableGraph:
# create the graph
graph = DrawableGraph()
start_nodes: dict[str, DrawableNode] = {
START: graph.add_node(self.get_input_schema(config), START)
}
end_nodes: dict[str, DrawableNode] = {}
if xray:
subgraphs = {
k: v for k, v in self.get_subgraphs() if isinstance(v, CompiledGraph)
}
else:
subgraphs = {}
def add_edge(
start: str,
@@ -463,13 +515,8 @@ class CompiledGraph(Pregel):
metadata["__interrupt"] = "before"
elif key in self.interrupt_after_nodes:
metadata["__interrupt"] = "after"
if xray and key in subgraphs:
subgraph = subgraphs[key].get_graph(
config=config,
xray=xray - 1
if isinstance(xray, int) and not isinstance(xray, bool) and xray > 0
else xray,
)
if key in subgraphs:
subgraph = subgraphs[key]
subgraph.trim_first_node()
subgraph.trim_last_node()
if len(subgraph.nodes) >= 1:
@@ -0,0 +1,210 @@
import logging
import weakref
from inspect import isclass
from typing import (
Any,
Callable,
Optional,
Type,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated
logger = logging.getLogger(__name__)
_cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]] = (
weakref.WeakKeyDictionary()
)
class SchemaCoercionMapper:
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]
inst = super().__new__(cls)
_cache[schema][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"):
return
self._inited = True
self.schema = schema
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):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.__fields__.items()
}
self._construct = schema.construct
else:
raise TypeError("Schema is neither valid Pydantic v1 nor v2 model.")
self._field_coercers: Optional[dict[str, Callable[[Any, Any], Any]]] = None
def __call__(self, input_data: Any, depth: Optional[int] = None) -> Any:
return self.coerce(input_data, depth)
def coerce(self, input_data: Any, depth: Optional[int] = None) -> Any:
if depth is None:
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()
}
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
) -> Callable[[Any, Any], Any]:
if depth == 0:
return self._passthrough
origin = get_origin(field_type)
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):
is_class_ = True
try:
is_base_model = issubclass(field_type, BaseModel)
except TypeError:
is_class_ = False
is_base_model = False
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:
args = get_args(field_type)
if len(args) != 1:
return lambda v, d: v
sub = self._build_coercer(args[0], depth - 1)
def list_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple)):
return v
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)
def set_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple, set)):
return v
return {sub(x, d - 1) for x in v}
return set_coercer
if origin is dict or field_type is dict:
args = get_args(field_type)
if len(args) != 2:
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError("Expected dict, got %s" % type(v))
return v
return dict_coercer
k_sub = self._build_coercer(args[0], depth - 1)
v_sub = self._build_coercer(args[1], depth - 1)
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError("Expected dict, got %s" % type(v))
return v
return {k_sub(k, d - 1): v_sub(val, d - 1) for k, val in v.items()}
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]
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
for ix, arg in enumerate(uargs):
if arg is type(None):
none_in_union = True
else:
subs.append(
self._build_coercer(arg, depth - 1, throw=ix < len(uargs) - 1)
)
def union_coercer(v: Any, d: Any) -> Any:
if v is None and none_in_union:
return None
err = None
for sp in subs:
try:
return sp(v, d - 1)
except TypeError as e:
err = e
if err:
raise err
return v
return union_coercer
return self._passthrough
def _passthrough(self, v: Any, d: Any) -> Any:
return v
+88 -68
View File
@@ -50,6 +50,7 @@ from langgraph.graph.graph import (
Graph,
Send,
)
from langgraph.graph.schema_utils import SchemaCoercionMapper
from langgraph.managed.base import (
ChannelKeyPlaceholder,
ChannelTypePlaceholder,
@@ -184,6 +185,7 @@ class StateGraph(Graph):
self.schemas = {}
self.channels = {}
self.managed = {}
self.type_hints: dict[Type[Any], dict[str, Any]] = {}
self.schema = state_schema
self.input = input
self.output = output
@@ -202,7 +204,7 @@ class StateGraph(Graph):
def _add_schema(self, schema: Type[Any], /, allow_managed: bool = True) -> None:
if schema not in self.schemas:
_warn_invalid_state_schema(schema)
channels, managed = _get_channels(schema)
channels, managed, type_hints = _get_channels(schema)
if managed and not allow_managed:
names = ", ".join(managed)
schema_name = getattr(schema, "__name__", "")
@@ -211,6 +213,7 @@ class StateGraph(Graph):
" Managed channels are not permitted in Input/Output schema."
)
self.schemas[schema] = {**channels, **managed}
self.type_hints[schema] = type_hints
for key, channel in channels.items():
if key in self.channels:
if self.channels[key] != channel:
@@ -415,7 +418,7 @@ class StateGraph(Graph):
and (vals := get_args(rargs[0]))
):
ends = vals
except (TypeError, StopIteration):
except (NameError, TypeError, StopIteration):
pass
if destinations is not None:
@@ -626,11 +629,13 @@ class StateGraph(Graph):
compiled = CompiledStateGraph(
builder=self,
config_type=self.config_schema,
input_model=self.input
if len(self.channels) > 1
and isclass(self.input)
and issubclass(self.input, (BaseModel, BaseModelV1))
else None,
input_model=(
self.input
if len(self.channels) > 1
and isclass(self.input)
and issubclass(self.input, (BaseModel, BaseModelV1))
else None
),
nodes={},
channels={
**self.channels,
@@ -759,24 +764,32 @@ class CompiledStateGraph(CompiledGraph):
else:
updates.extend(_get_updates(i) or ())
return updates
elif get_type_hints(type(input)):
# if input is a Pydantic model, only update values
# for the keys that have been explicitly set by the users
# (this is needed to avoid sending updates for fields with None defaults)
output_keys_ = output_keys
elif (t := type(input)) and get_type_hints(t):
# Pydantic v2
if hasattr(input, "model_fields_set"):
output_keys_ = [
k for k in output_keys if k in input.model_fields_set
]
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 hasattr(input, "__fields_set__"):
output_keys_ = [k for k in output_keys if k in input.__fields_set__]
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, getattr(input, k))
for k in output_keys_
if getattr(input, k, MISSING) is not MISSING
(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)
)
]
else:
msg = create_error_message(
@@ -802,7 +815,6 @@ class CompiledStateGraph(CompiledGraph):
ChannelWrite(
write_entries,
tags=[TAG_HIDDEN],
require_at_least_one_of=output_keys,
),
],
)
@@ -811,13 +823,19 @@ class CompiledStateGraph(CompiledGraph):
input_values = {k: k for k in self.builder.schemas[input_schema]}
is_single_input = len(input_values) == 1 and "__root__" in input_values
branch_channel = f"branch:to:{key}"
self.channels[key] = EphemeralValue(Any, guard=False)
self.channels[branch_channel] = EphemeralValue(Any, guard=False)
self.nodes[key] = PregelNode(
triggers=[],
triggers=[branch_channel],
# 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),
mapper=_pick_mapper(
list(input_values),
input_schema,
self.builder.type_hints[input_schema],
),
writers=[
# publish to this channel and state keys
ChannelWrite(
@@ -841,8 +859,10 @@ class CompiledStateGraph(CompiledGraph):
# subscribe to channel
self.nodes[end].triggers.append(channel_name)
# publish to channel
self.nodes[START] |= ChannelWrite(
[ChannelWriteEntry(channel_name, START)], tags=[TAG_HIDDEN]
self.nodes[START].writers.append(
ChannelWrite(
[ChannelWriteEntry(channel_name, START)], tags=[TAG_HIDDEN]
)
)
elif end != END:
# subscribe to start channel
@@ -855,8 +875,10 @@ class CompiledStateGraph(CompiledGraph):
self.nodes[end].triggers.append(channel_name)
# publish to channel
for start in starts:
self.nodes[start] |= ChannelWrite(
[ChannelWriteEntry(channel_name, start)], tags=[TAG_HIDDEN]
self.nodes[start].writers.append(
ChannelWrite(
[ChannelWriteEntry(channel_name, start)], tags=[TAG_HIDDEN]
)
)
def attach_branch(
@@ -868,7 +890,7 @@ class CompiledStateGraph(CompiledGraph):
if filtered := [p for p in packets if p != END]:
writes = [
(
ChannelWriteEntry(f"branch:{start}:{name}:{p}", start)
ChannelWriteEntry(f"branch:to:{p}", start)
if not isinstance(p, Send)
else p
)
@@ -892,33 +914,31 @@ class CompiledStateGraph(CompiledGraph):
if start in self.builder.nodes
else self.builder.schema
)
# attach branch publisher
self.nodes[start] |= branch.run(
branch_writer,
_get_state_reader(self.builder, schema) if with_reader else None,
)
# attach branch subscribers
ends = (
branch.ends.values()
if branch.ends
else [node for node in self.builder.nodes if node != branch.then]
# attach branch publisher
self.nodes[start].writers.append(
branch.run(
branch_writer,
_get_state_reader(self.builder, schema) if with_reader else None,
)
)
for end in ends:
if end != END:
channel_name = f"branch:{start}:{name}:{end}"
self.channels[channel_name] = EphemeralValue(Any, guard=False)
self.nodes[end].triggers.append(channel_name)
# attach then subscriber
if branch.then and branch.then != END:
ends = (
branch.ends.values()
if branch.ends
else [node for node in self.builder.nodes if node != branch.then]
)
channel_name = f"branch:{start}:{name}::then"
self.channels[channel_name] = DynamicBarrierValue(str)
self.nodes[branch.then].triggers.append(channel_name)
for end in ends:
if end != END:
self.nodes[end] |= ChannelWrite(
[ChannelWriteEntry(channel_name, end)], tags=[TAG_HIDDEN]
self.nodes[end].writers.append(
ChannelWrite(
[ChannelWriteEntry(channel_name, end)], tags=[TAG_HIDDEN]
)
)
@@ -932,34 +952,23 @@ def _get_state_reader(
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),
mapper=_pick_mapper(state_keys, schema, builder.type_hints[schema]),
)
def _pick_mapper(
state_keys: Sequence[str], schema: Type[Any]
state_keys: Sequence[str], schema: Type[Any], type_hints: Optional[dict[str, Any]]
) -> Optional[Callable[[Any], Any]]:
if state_keys == ["__root__"]:
return None
if issubclass(schema, dict):
return None
if issubclass(schema, BaseModel):
return partial(_coerce_state_pydantic, schema)
if issubclass(schema, BaseModelV1):
return partial(_coerce_state_pydantic_v1, schema)
if isclass(schema):
if issubclass(schema, dict):
return None
if issubclass(schema, (BaseModel, BaseModelV1)):
return SchemaCoercionMapper(schema, type_hints)
return partial(_coerce_state, schema)
def _coerce_state_pydantic(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
return schema.model_construct(**input)
def _coerce_state_pydantic_v1(
schema: Type[Any], input: dict[str, Any]
) -> dict[str, Any]:
return schema.construct(**input)
def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
return schema(**input)
@@ -1011,25 +1020,36 @@ async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]:
CONTROL_BRANCH_PATH = RunnableCallable(
_control_branch, _acontrol_branch, tags=[TAG_HIDDEN], trace=False, recurse=False
_control_branch,
_acontrol_branch,
tags=[TAG_HIDDEN],
trace=False,
recurse=False,
func_accepts_config=False,
)
CONTROL_BRANCH = Branch(CONTROL_BRANCH_PATH, None)
def _get_channels(
schema: Type[dict],
) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec]]:
) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec], dict[str, Any]]:
if not hasattr(schema, "__annotations__"):
return {"__root__": _get_channel("__root__", schema, allow_managed=False)}, {}
return (
{"__root__": _get_channel("__root__", schema, allow_managed=False)},
{},
{},
)
type_hints = get_type_hints(schema, include_extras=True)
all_keys = {
name: _get_channel(name, typ)
for name, typ in get_type_hints(schema, include_extras=True).items()
for name, typ in type_hints.items()
if name != "__slots__"
}
return (
{k: v for k, v in all_keys.items() if isinstance(v, BaseChannel)},
{k: v for k, v in all_keys.items() if is_managed_value(v)},
type_hints,
)
File diff suppressed because it is too large Load Diff
+150 -35
View File
@@ -1,4 +1,4 @@
import functools
import binascii
import itertools
import sys
from collections import defaultdict, deque
@@ -19,7 +19,6 @@ from typing import (
cast,
overload,
)
from uuid import UUID
from langchain_core.callbacks import Callbacks
from langchain_core.callbacks.manager import AsyncParentRunManager, ParentRunManager
@@ -28,6 +27,7 @@ from langchain_core.runnables.config import RunnableConfig
from langgraph.channels.base import BaseChannel
from langgraph.checkpoint.base import (
BaseCheckpointSaver,
ChannelVersions,
Checkpoint,
PendingWrite,
V,
@@ -233,10 +233,21 @@ def apply_writes(
channels: Mapping[str, BaseChannel],
tasks: Iterable[WritesProtocol],
get_next_version: Optional[GetNextVersion],
) -> dict[str, list[Any]]:
) -> tuple[dict[str, list[Any]], set[str]]:
"""Apply writes from a set of tasks (usually the tasks from a Pregel step)
to the checkpoint and channels, and return managed values writes to be applied
externally."""
externally.
Args:
checkpoint: The checkpoint to update.
channels: The channels to update.
tasks: The tasks to apply writes from.
get_next_version: Optional function to determine the next version of a channel.
Returns:
A tuple containing the managed values writes to be applied externally, and
the set of channels that were updated in this step.
"""
# sort tasks on path, to ensure deterministic order for update application
# any path parts after the 3rd are ignored for sorting
# (we use them for eg. task ids which aren't good for sorting)
@@ -312,15 +323,14 @@ def apply_writes(
# Channels that weren't updated in this step are notified of a new step
if bump_step:
for chan in channels:
if chan not in updated_channels:
if channels[chan].update([]) and get_next_version is not None:
if channels[chan].is_available() and chan not in updated_channels:
if channels[chan].update(EMPTY_SEQ) and get_next_version is not None:
checkpoint["channel_versions"][chan] = get_next_version(
max_version,
channels[chan],
)
# Return managed values writes to be applied externally
return pending_writes_by_managed
return pending_writes_by_managed, updated_channels
@overload
@@ -337,6 +347,8 @@ def prepare_next_tasks(
store: Literal[None] = None,
checkpointer: Literal[None] = None,
manager: Literal[None] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
updated_channels: Optional[set[str]] = None,
) -> dict[str, PregelTask]: ...
@@ -354,6 +366,8 @@ def prepare_next_tasks(
store: Optional[BaseStore],
checkpointer: Optional[BaseCheckpointSaver],
manager: Union[None, ParentRunManager, AsyncParentRunManager],
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
updated_channels: Optional[set[str]] = None,
) -> dict[str, PregelExecutableTask]: ...
@@ -370,10 +384,37 @@ def prepare_next_tasks(
store: Optional[BaseStore] = None,
checkpointer: Optional[BaseCheckpointSaver] = None,
manager: Union[None, ParentRunManager, AsyncParentRunManager] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
updated_channels: Optional[set[str]] = None,
) -> Union[dict[str, PregelTask], dict[str, PregelExecutableTask]]:
"""Prepare the set of tasks that will make up the next Pregel step.
This is the union of all PUSH tasks (Sends) and PULL tasks (nodes triggered
by edges)."""
Args:
checkpoint: The current checkpoint.
pending_writes: The list of pending writes.
processes: The mapping of process names to PregelNode instances.
channels: The mapping of channel names to BaseChannel instances.
managed: The mapping of managed value names to functions.
config: The runnable configuration.
step: The current step.
for_execution: Whether the tasks are being prepared for execution.
store: An instance of BaseStore to make it available for usage within tasks.
checkpointer: Checkpointer instance used for saving checkpoints.
manager: The parent run manager to use for the tasks.
trigger_to_nodes: Optional: Mapping of channel names to the set of nodes
that are can be triggered by that channel.
updated_channels: Optional. Set of channel names that have been updated during
the previous step. Using in conjunction with trigger_to_nodes to speed
up the process of determining which nodes should be triggered in the next
step.
Returns:
A dictionary of tasks to be executed. The keys are the task ids and the values
are the tasks themselves. This is the union of all PUSH tasks (Sends)
and PULL tasks (nodes triggered by edges).
"""
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
null_version = checkpoint_null_version(checkpoint)
tasks: list[Union[PregelTask, PregelExecutableTask]] = []
# Consume pending_sends from previous step
for idx, _ in enumerate(checkpoint["pending_sends"]):
@@ -381,6 +422,8 @@ def prepare_next_tasks(
(PUSH, idx),
None,
checkpoint=checkpoint,
checkpoint_id_bytes=checkpoint_id_bytes,
checkpoint_null_version=null_version,
pending_writes=pending_writes,
processes=processes,
channels=channels,
@@ -393,13 +436,36 @@ def prepare_next_tasks(
manager=manager,
):
tasks.append(task)
# This section is an optimization that allows which nodes will be active
# during the next step.
# When there's information about:
# 1. Which channels were updated in the previous step
# 2. Which nodes are triggered by which channels
# Then we can determine which nodes should be triggered in the next step
# without having to cycle through all nodes.
if updated_channels and trigger_to_nodes:
triggered_nodes: set[str] = set()
# Get all nodes that have triggers associated with an updated channel
for channel in updated_channels:
if node_ids := trigger_to_nodes.get(channel):
triggered_nodes.update(node_ids)
# Sort the nodes to ensure deterministic order
candidate_nodes: Iterable[str] = sorted(triggered_nodes)
elif not checkpoint["channel_versions"]:
candidate_nodes = ()
else:
candidate_nodes = processes.keys()
# Check if any processes should be run in next step
# If so, prepare the values to be passed to them
for name in processes:
for name in candidate_nodes:
if task := prepare_single_task(
(PULL, name),
None,
checkpoint=checkpoint,
checkpoint_id_bytes=checkpoint_id_bytes,
checkpoint_null_version=null_version,
pending_writes=pending_writes,
processes=processes,
channels=channels,
@@ -415,11 +481,16 @@ def prepare_next_tasks(
return {t.id: t for t in tasks}
PUSH_TRIGGER = (PUSH,)
def prepare_single_task(
task_path: tuple[Any, ...],
task_id_checksum: Optional[str],
*,
checkpoint: Checkpoint,
checkpoint_id_bytes: bytes,
checkpoint_null_version: Optional[V],
pending_writes: list[PendingWrite],
processes: Mapping[str, PregelNode],
channels: Mapping[str, BaseChannel],
@@ -433,7 +504,6 @@ def prepare_single_task(
) -> 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."""
checkpoint_id = UUID(checkpoint["id"]).bytes
configurable = config.get(CONF, {})
parent_ns = configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
@@ -446,10 +516,10 @@ def prepare_single_task(
if name is None:
raise ValueError("`call` functions must have a `__name__` attribute")
# create task id
triggers = [PUSH]
triggers: Sequence[str] = PUSH_TRIGGER
checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name
task_id = _uuid5_str(
checkpoint_id,
checkpoint_id_bytes,
checkpoint_ns,
str(step),
name,
@@ -507,6 +577,7 @@ def prepare_single_task(
CONFIG_KEY_CHECKPOINT_ID: None,
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
CONFIG_KEY_SCRATCHPAD: _scratchpad(
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
),
@@ -539,12 +610,12 @@ def prepare_single_task(
)
return
# create task id
triggers = [PUSH]
triggers = PUSH_TRIGGER
checkpoint_ns = (
f"{parent_ns}{NS_SEP}{packet.node}" if parent_ns else packet.node
)
task_id = _uuid5_str(
checkpoint_id,
checkpoint_id_bytes,
checkpoint_ns,
str(step),
packet.node,
@@ -616,6 +687,7 @@ def prepare_single_task(
CONFIG_KEY_CHECKPOINT_ID: None,
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
CONFIG_KEY_SCRATCHPAD: _scratchpad(
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
),
@@ -640,21 +712,17 @@ def prepare_single_task(
if name not in processes:
return
proc = processes[name]
version_type = type(next(iter(checkpoint["channel_versions"].values()), None))
null_version = version_type() # type: ignore[misc]
if null_version is None:
if checkpoint_null_version is None:
return
seen = checkpoint["versions_seen"].get(name, {})
# If any of the channels read by this process were updated
if triggers := sorted(
chan
for chan in proc.triggers
if not isinstance(
read_channel(channels, chan, return_exception=True), EmptyChannelError
)
and checkpoint["channel_versions"].get(chan, null_version) # type: ignore[operator]
> seen.get(chan, null_version)
if _triggers(
channels,
checkpoint["channel_versions"],
checkpoint["versions_seen"].get(name),
checkpoint_null_version,
proc,
):
triggers = tuple(sorted(proc.triggers))
try:
val = next(
_proc_input(proc, managed, channels, for_execution=for_execution)
@@ -671,7 +739,7 @@ def prepare_single_task(
# create task id
checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name
task_id = _uuid5_str(
checkpoint_id,
checkpoint_id_bytes,
checkpoint_ns,
str(step),
name,
@@ -714,7 +782,7 @@ def prepare_single_task(
CONFIG_KEY_SEND: partial(
local_write,
writes.extend,
processes.keys(),
tuple(processes.keys()),
),
CONFIG_KEY_READ: partial(
local_read,
@@ -723,7 +791,10 @@ def prepare_single_task(
channels,
managed,
PregelTaskWrites(
task_path[:3], name, writes, triggers
task_path[:3],
name,
writes,
triggers,
),
config,
),
@@ -741,6 +812,7 @@ def prepare_single_task(
CONFIG_KEY_CHECKPOINT_ID: None,
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
CONFIG_KEY_SCRATCHPAD: _scratchpad(
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
pending_writes,
task_id,
),
@@ -761,13 +833,59 @@ def prepare_single_task(
return PregelTask(task_id, name, task_path[:3])
def checkpoint_null_version(
checkpoint: Checkpoint,
) -> Optional[V]:
"""Get the null version for the checkpoint, if available."""
for version in checkpoint["channel_versions"].values():
return type(version)()
return None
def _triggers(
channels: Mapping[str, BaseChannel],
versions: ChannelVersions,
seen: Optional[ChannelVersions],
null_version: V,
proc: PregelNode,
) -> Sequence[str]:
if seen is None:
for chan in proc.triggers:
if channels[chan].is_available():
return (chan,)
else:
for chan in proc.triggers:
if channels[chan].is_available() and versions.get( # type: ignore[operator]
chan, null_version
) > seen.get(chan, null_version):
return (chan,)
return EMPTY_SEQ
def _scratchpad(
parent_scratchpad: Optional[PregelScratchpad],
pending_writes: list[PendingWrite],
task_id: str,
) -> PregelScratchpad:
# None cannot be used as a resume value, because it would be difficult to
# distinguish from missing when used over http
null_resume_write = next(
(w for w in pending_writes if w[0] == NULL_TASK_ID and w[1] == RESUME), None
)
def get_null_resume(consume: bool = False) -> Any:
if null_resume_write is None:
if parent_scratchpad is not None:
return parent_scratchpad.get_null_resume(consume)
return None
if consume:
try:
pending_writes.remove(null_resume_write)
return null_resume_write[2]
except ValueError:
return None
return null_resume_write[2]
# using itertools.count as an atomic counter (+= 1 is not thread-safe)
return PregelScratchpad(
# call
@@ -777,10 +895,7 @@ def _scratchpad(
resume=next(
(w[2] for w in pending_writes if w[0] == task_id and w[1] == RESUME), []
),
null_resume=null_resume_write[2] if null_resume_write is not None else None,
_consume_null_resume=functools.partial(pending_writes.remove, null_resume_write)
if null_resume_write is not None
else lambda: None,
get_null_resume=get_null_resume,
# subgraph
subgraph_counter=itertools.count(0).__next__,
)
+64 -40
View File
@@ -25,8 +25,10 @@ from langgraph.constants import (
CONFIG_KEY_CHECKPOINT_NS,
ERROR,
INTERRUPT,
MISSING,
NS_END,
NS_SEP,
RETURN,
TAG_HIDDEN,
)
from langgraph.pregel.io import read_channels
@@ -132,8 +134,15 @@ def map_debug_task_results(
"id": task.id,
"name": task.name,
"error": next((w[1] for w in writes if w[0] == ERROR), None),
"result": [w for w in writes if w[0] in stream_channels_list],
"interrupts": [asdict(w[1]) for w in writes if w[0] == INTERRUPT],
"result": [
w for w in writes if w[0] in stream_channels_list or w[0] == RETURN
],
"interrupts": [
asdict(v)
for w in writes
if w[0] == INTERRUPT
for v in (w[1] if isinstance(w[1], Sequence) else [w[1]])
],
},
}
@@ -264,49 +273,64 @@ def tasks_w_writes(
) -> tuple[PregelTask, ...]:
"""Apply writes / subgraph states to tasks to be returned in a StateSnapshot."""
pending_writes = pending_writes or []
return tuple(
PregelTask(
task.id,
task.name,
task.path,
next(
(
exc
for tid, n, exc in pending_writes
if tid == task.id and n == ERROR
),
None,
),
tuple(
v for tid, n, v in pending_writes if tid == task.id and n == INTERRUPT
),
states.get(task.id) if states else None,
out: list[PregelTask] = []
for task in tasks:
rtn = next(
(
val
for tid, chan, val in pending_writes
if tid == task.id and chan == RETURN
),
MISSING,
)
out.append(
PregelTask(
task.id,
task.name,
task.path,
next(
(
val
for tid, chan, val in pending_writes
if tid == task.id and chan == output_keys
exc
for tid, n, exc in pending_writes
if tid == task.id and n == ERROR
),
None,
)
if isinstance(output_keys, str)
else {
chan: val
for tid, chan, val in pending_writes
if tid == task.id
and (
chan == output_keys
if isinstance(output_keys, str)
else chan in output_keys
),
tuple(
v
for tid, n, vv in pending_writes
if tid == task.id and n == INTERRUPT
for v in (vv if isinstance(vv, Sequence) else [vv])
),
states.get(task.id) if states else None,
(
rtn
if rtn is not MISSING
else next(
(
val
for tid, chan, val in pending_writes
if tid == task.id and chan == output_keys
),
None,
)
}
if isinstance(output_keys, str)
else {
chan: val
for tid, chan, val in pending_writes
if tid == task.id
and (
chan == output_keys
if isinstance(output_keys, str)
else chan in output_keys
)
}
)
if any(
w[0] == task.id and w[1] not in (ERROR, INTERRUPT)
for w in pending_writes
)
else None,
)
if any(
w[0] == task.id and w[1] not in (ERROR, INTERRUPT)
for w in pending_writes
)
else None,
)
for task in tasks
)
return tuple(out)
+4 -8
View File
@@ -14,7 +14,6 @@ from langgraph.constants import (
NULL_TASK_ID,
RESUME,
RETURN,
SELF,
START,
TAG_HIDDEN,
TASKS,
@@ -28,7 +27,7 @@ def is_task_id(task_id: str) -> bool:
"""Check if a string is a valid task id."""
try:
UUID(task_id)
except ValueError:
except Exception:
return False
return True
@@ -38,14 +37,11 @@ def read_channel(
chan: str,
*,
catch: bool = True,
return_exception: bool = False,
) -> Any:
try:
return channels[chan].get()
except EmptyChannelError as exc:
if return_exception:
return exc
elif catch:
except EmptyChannelError:
if catch:
return None
else:
raise
@@ -84,7 +80,7 @@ def map_command(
if isinstance(send, Send):
yield (NULL_TASK_ID, TASKS, send)
elif isinstance(send, str):
yield (NULL_TASK_ID, f"branch:{START}:{SELF}:{send}", START)
yield (NULL_TASK_ID, f"branch:to:{send}", START)
else:
raise TypeError(
f"In Command.goto, expected Send/str, got {type(send).__name__}"
+58 -26
View File
@@ -1,5 +1,7 @@
import asyncio
import binascii
import concurrent.futures
import dataclasses
from collections import defaultdict, deque
from contextlib import AsyncExitStack, ExitStack
from inspect import signature
@@ -79,6 +81,7 @@ from langgraph.pregel.algo import (
GetNextVersion,
PregelTaskWrites,
apply_writes,
checkpoint_null_version,
increment,
prepare_next_tasks,
prepare_single_task,
@@ -207,6 +210,7 @@ class PregelLoop(LoopProtocol):
manager: Union[None, AsyncParentRunManager, ParentRunManager] = None,
input_model: Optional[Type[BaseModel]] = None,
debug: bool = False,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
) -> None:
super().__init__(
step=0,
@@ -230,6 +234,7 @@ class PregelLoop(LoopProtocol):
CONFIG_KEY_CHECKPOINT_ID not in config[CONF]
or CONFIG_KEY_DEDUPE_TASKS in config[CONF]
)
self.trigger_to_nodes = trigger_to_nodes
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])
@@ -264,13 +269,13 @@ class PregelLoop(LoopProtocol):
self.checkpoint_config = patch_configurable(
self.config,
{
CONFIG_KEY_CHECKPOINT_ID: config[CONF][CONFIG_KEY_CHECKPOINT_MAP][
self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]
]
CONFIG_KEY_CHECKPOINT_ID: self.config[CONF][
CONFIG_KEY_CHECKPOINT_MAP
][self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]]
},
)
else:
self.checkpoint_config = config
self.checkpoint_config = self.config
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)
@@ -347,12 +352,16 @@ class PregelLoop(LoopProtocol):
):
self.to_interrupt.append(task)
return
checkpoint_id_bytes = binascii.unhexlify(self.checkpoint["id"].replace("-", ""))
null_version = checkpoint_null_version(self.checkpoint)
if pushed := cast(
Optional[PregelExecutableTask],
prepare_single_task(
(PUSH, task.path, write_idx, task.id, call),
None,
checkpoint=self.checkpoint,
checkpoint_id_bytes=checkpoint_id_bytes,
checkpoint_null_version=null_version,
pending_writes=self.checkpoint_pending_writes,
processes=self.nodes,
channels=self.channels,
@@ -400,8 +409,10 @@ class PregelLoop(LoopProtocol):
if self.status != "pending":
raise RuntimeError("Cannot tick when status is no longer 'pending'")
updated_channels: set[str] | None = None
if self.input not in (INPUT_DONE, INPUT_RESUMING, INPUT_SHOULD_VALIDATE):
self._first(input_keys=input_keys)
updated_channels = self._first(input_keys=input_keys)
elif self.to_interrupt:
# if we need to interrupt, do so
self.status = "interrupt_before"
@@ -421,7 +432,7 @@ class PregelLoop(LoopProtocol):
),
)
# all tasks have finished
mv_writes = apply_writes(
mv_writes, updated_channels = apply_writes(
self.checkpoint,
self.channels,
self.tasks.values(),
@@ -487,6 +498,8 @@ class PregelLoop(LoopProtocol):
manager=self.manager,
store=self.store,
checkpointer=self.checkpointer,
trigger_to_nodes=self.trigger_to_nodes,
updated_channels=updated_channels,
)
self.to_interrupt = []
@@ -565,11 +578,11 @@ class PregelLoop(LoopProtocol):
self.checkpoint["versions_seen"].get(INTERRUPT, {}).values(),
default=None,
):
self.tasks[tid] = task._replace(scheduled=True)
self.tasks[tid] = dataclasses.replace(task, scheduled=True)
else:
task.writes.append((k, v))
def _first(self, *, input_keys: Union[str, Sequence[str]]) -> None:
def _first(self, *, input_keys: Union[str, Sequence[str]]) -> Optional[set[str]]:
# resuming from previous checkpoint requires
# - finding a previous checkpoint
# - receiving None input (outer graph) or RESUMING flag (subgraph)
@@ -586,16 +599,9 @@ class PregelLoop(LoopProtocol):
),
)
)
# this can be set only when there are input_writes
updated_channels: Optional[set[str]] = None
# take resume value from parent
if scratchpad := cast(
Optional[PregelScratchpad], configurable.get(CONFIG_KEY_SCRATCHPAD)
):
if (
isinstance(scratchpad, PregelScratchpad)
and scratchpad.null_resume is not None
):
self.put_writes(NULL_TASK_ID, [(RESUME, scratchpad.null_resume)])
# map command to writes
if isinstance(self.input, Command):
if self.input.resume is not None and not self.checkpointer:
@@ -615,7 +621,7 @@ class PregelLoop(LoopProtocol):
if null_writes := [
w[1:] for w in self.checkpoint_pending_writes if w[0] == NULL_TASK_ID
]:
mv_writes = apply_writes(
mv_writes, _ = apply_writes(
self.checkpoint,
self.channels,
[PregelTaskWrites((), INPUT, null_writes, [])],
@@ -664,7 +670,7 @@ class PregelLoop(LoopProtocol):
manager=None,
)
# apply input writes
mv_writes = apply_writes(
mv_writes, updated_channels = apply_writes(
self.checkpoint,
self.channels,
[
@@ -694,6 +700,7 @@ class PregelLoop(LoopProtocol):
self.config = patch_configurable(
self.config, {CONFIG_KEY_RESUMING: is_resuming}
)
return updated_channels
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
for k, v in self.config["metadata"].items():
@@ -779,7 +786,7 @@ class PregelLoop(LoopProtocol):
and self.checkpoint_pending_writes
and any(task.writes for task in self.tasks.values())
):
mv_writes = apply_writes(
mv_writes, _ = apply_writes(
self.checkpoint,
self.channels,
self.tasks.values(),
@@ -794,11 +801,14 @@ class PregelLoop(LoopProtocol):
[w for t in self.tasks.values() for w in t.writes],
self.channels,
)
# emit INTERRUPT event
self._emit(
"updates",
lambda: iter([{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]),
)
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
self._emit(
"updates",
lambda: iter(
[{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]
),
)
# save final output
self.output = read_channels(self.channels, self.output_keys)
# suppress interrupt
@@ -829,7 +839,25 @@ class PregelLoop(LoopProtocol):
"tags", EMPTY_SEQ
):
return
if writes[0][0] != ERROR and writes[0][0] != INTERRUPT:
if writes[0][0] == INTERRUPT:
self._emit(
"updates",
lambda: iter(
[
{
INTERRUPT: tuple(
v
for w in writes
if w[0] == INTERRUPT
for v in (
w[1] if isinstance(w[1], Sequence) else (w[1],)
)
)
}
]
),
)
elif writes[0][0] != ERROR:
self._emit(
"updates",
map_output_updates,
@@ -865,6 +893,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ,
input_model: Optional[Type[BaseModel]] = None,
debug: bool = False,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
) -> None:
super().__init__(
input,
@@ -881,6 +910,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
interrupt_before=interrupt_before,
manager=manager,
debug=debug,
trigger_to_nodes=trigger_to_nodes,
)
self.stack = ExitStack()
if checkpointer:
@@ -1006,6 +1036,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ,
input_model: Optional[Type[BaseModel]] = None,
debug: bool = False,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
) -> None:
super().__init__(
input,
@@ -1022,6 +1053,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
interrupt_before=interrupt_before,
manager=manager,
debug=debug,
trigger_to_nodes=trigger_to_nodes,
)
self.stack = AsyncExitStack()
if checkpointer:
+15 -1
View File
@@ -12,7 +12,7 @@ from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.runnables.graph import Graph as DrawableGraph
from typing_extensions import Self
from langgraph.pregel.types import All, StateSnapshot, StreamMode
from langgraph.pregel.types import All, StateSnapshot, StateUpdate, StreamMode
class PregelProtocol(
@@ -69,6 +69,20 @@ class PregelProtocol(
limit: Optional[int] = None,
) -> AsyncIterator[StateSnapshot]: ...
@abstractmethod
def bulk_update_state(
self,
config: RunnableConfig,
updates: Sequence[Sequence[StateUpdate]],
) -> RunnableConfig: ...
@abstractmethod
async def abulk_update_state(
self,
config: RunnableConfig,
updates: Sequence[Sequence[StateUpdate]],
) -> RunnableConfig: ...
@abstractmethod
def update_state(
self,
+11 -4
View File
@@ -62,7 +62,13 @@ class ChannelRead(RunnableCallable):
mapper: Optional[Callable[[Any], Any]] = None,
tags: Optional[list[str]] = None,
) -> None:
super().__init__(func=self._read, afunc=self._aread, tags=tags, name=None)
super().__init__(
func=self._read,
afunc=self._aread,
tags=tags,
name=None,
func_accepts_config=True,
)
self.fresh = fresh
self.mapper = mapper
self.channel = channel
@@ -161,6 +167,7 @@ class PregelNode(Runnable):
metadata: Optional[Mapping[str, Any]] = None,
bound: Optional[Runnable[Any, Any]] = None,
retry_policy: Optional[RetryPolicy] = None,
subgraphs: Optional[Sequence[PregelProtocol]] = None,
) -> None:
self.channels = channels
self.triggers = list(triggers)
@@ -170,7 +177,9 @@ class PregelNode(Runnable):
self.retry_policy = retry_policy
self.tags = tags
self.metadata = metadata
if self.bound is not DEFAULT_BOUND:
if subgraphs is not None:
self.subgraphs = subgraphs
elif self.bound is not DEFAULT_BOUND:
try:
subgraph = find_subgraph_pregel(self.bound)
except Exception:
@@ -184,7 +193,6 @@ class PregelNode(Runnable):
def copy(self, update: dict[str, Any]) -> PregelNode:
attrs = {**self.__dict__, **update}
attrs.pop("subgraphs")
return PregelNode(**attrs)
@cached_property
@@ -201,7 +209,6 @@ class PregelNode(Runnable):
writers[-2] = ChannelWrite(
writes=writers[-2].writes + writers[-1].writes,
tags=writers[-2].tags,
require_at_least_one_of=writers[-2].require_at_least_one_of,
)
writers.pop()
return writers
+14
View File
@@ -457,6 +457,20 @@ class RemoteGraph(PregelProtocol):
for state in states:
yield self._create_state_snapshot(state)
def bulk_update_state(
self,
config: RunnableConfig,
updates: list[tuple[Optional[dict[str, Any]], Optional[str]]],
) -> RunnableConfig:
raise NotImplementedError
async def abulk_update_state(
self,
config: RunnableConfig,
updates: list[tuple[Optional[dict[str, Any]], Optional[str]]],
) -> RunnableConfig:
raise NotImplementedError
def update_state(
self,
config: RunnableConfig,
+265 -233
View File
@@ -2,6 +2,7 @@ import asyncio
import concurrent.futures
import threading
import time
import weakref
from functools import partial
from typing import (
Any,
@@ -25,12 +26,10 @@ from langgraph.constants import (
CONF,
CONFIG_KEY_CALL,
CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_SEND,
ERROR,
INTERRUPT,
MISSING,
NO_WRITES,
PUSH,
RESUME,
RETURN,
TAG_HIDDEN,
@@ -48,7 +47,9 @@ E = TypeVar("E", threading.Event, asyncio.Event)
class FuturesDict(Generic[F, E], dict[F, Optional[PregelExecutableTask]]):
event: E
callback: Callable[[PregelExecutableTask, Optional[BaseException]], None]
callback: weakref.ref[
Callable[[PregelExecutableTask, Optional[BaseException]], None]
]
counter: int
done: set[F]
lock: threading.Lock
@@ -56,7 +57,9 @@ class FuturesDict(Generic[F, E], dict[F, Optional[PregelExecutableTask]]):
def __init__(
self,
event: E,
callback: Callable[[PregelExecutableTask, Optional[BaseException]], None],
callback: weakref.ref[
Callable[[PregelExecutableTask, Optional[BaseException]], None]
],
future_type: Type[F],
# used for generic typing, newer py supports FutureDict[...](...)
) -> None:
@@ -85,7 +88,7 @@ class FuturesDict(Generic[F, E], dict[F, Optional[PregelExecutableTask]]):
fut: F,
) -> None:
try:
self.callback(task, _exception(fut))
self.callback()(task, _exception(fut)) # type: ignore[misc]
finally:
with self.lock:
self.done.add(fut)
@@ -102,10 +105,13 @@ class PregelRunner:
def __init__(
self,
*,
submit: Submit,
put_writes: Callable[[str, Sequence[tuple[str, Any]]], None],
schedule_task: Callable[
[PregelExecutableTask, int, Optional[Call]], Optional[PregelExecutableTask]
submit: weakref.ref[Submit],
put_writes: weakref.ref[Callable[[str, Sequence[tuple[str, Any]]], None]],
schedule_task: weakref.ref[
Callable[
[PregelExecutableTask, int, Optional[Call]],
Optional[PregelExecutableTask],
]
],
use_astream: bool = False,
node_finished: Optional[Callable[[str], None]] = None,
@@ -125,99 +131,9 @@ class PregelRunner:
retry_policy: Optional[RetryPolicy] = None,
get_waiter: Optional[Callable[[], concurrent.futures.Future[None]]] = None,
) -> Iterator[None]:
def writer(
task: PregelExecutableTask,
writes: Sequence[tuple[str, Any]],
*,
calls: Optional[Sequence[Call]] = None,
) -> Sequence[Optional[concurrent.futures.Future]]:
if all(w[0] != PUSH for w in writes):
return task.config[CONF][CONFIG_KEY_SEND](writes)
# schedule PUSH tasks, collect futures
scratchpad: PregelScratchpad = task.config[CONF][CONFIG_KEY_SCRATCHPAD]
rtn: dict[int, Optional[concurrent.futures.Future]] = {}
for idx, w in enumerate(writes):
# bail if not a PUSH write
if w[0] != PUSH:
continue
# schedule the next task, if the callback returns one
wcall = calls[idx] if calls else None
if next_task := self.schedule_task(
task, scratchpad.call_counter(), wcall
):
if fut := next(
(
f
for f, t in futures.items()
if t is not None and t == next_task.id
),
None,
):
# if the parent task was retried,
# the next task might already be running
rtn[idx] = fut
elif next_task.writes:
# if it already ran, return the result
fut = concurrent.futures.Future()
ret = next(
(v for c, v in next_task.writes if c == RETURN), MISSING
)
if ret is not MISSING:
fut.set_result(ret)
elif exc := next(
(v for c, v in next_task.writes if c == ERROR), None
):
fut.set_exception(
exc
if isinstance(exc, BaseException)
else Exception(exc)
)
else:
fut.set_result(None)
rtn[idx] = fut
else:
# schedule the next task
fut = self.submit(
run_with_retry,
next_task,
retry_policy,
configurable={
CONFIG_KEY_SEND: partial(writer, next_task),
CONFIG_KEY_CALL: partial(call, next_task),
},
__reraise_on_exit__=reraise,
# starting a new task in the next tick ensures
# updates from this tick are committed/streamed first
__next_tick__=True,
)
futures[fut] = next_task
rtn[idx] = fut
return [rtn.get(i) for i in range(len(writes))]
def call(
task: PregelExecutableTask,
func: Callable[[Any], Union[Awaitable[Any], Any]],
input: Any,
*,
retry: Optional[RetryPolicy] = None,
callbacks: Callbacks = None,
) -> concurrent.futures.Future[Any]:
if asyncio.iscoroutinefunction(func):
raise RuntimeError("In an sync context async tasks cannot be called")
(fut,) = writer(
task,
[(PUSH, None)],
calls=[Call(func, input, retry=retry, callbacks=callbacks)],
)
assert fut is not None, "writer did not return a future for call"
# return a chained future to ensure commit() callback is called
# before the returned future is resolved, to ensure stream order etc
return chain_future(fut, concurrent.futures.Future())
tasks = tuple(tasks)
futures = FuturesDict(
callback=self.commit,
callback=weakref.WeakMethod(self.commit),
event=threading.Event(),
future_type=concurrent.futures.Future,
)
@@ -231,8 +147,15 @@ class PregelRunner:
t,
retry_policy,
configurable={
CONFIG_KEY_SEND: partial(writer, t),
CONFIG_KEY_CALL: partial(call, t),
CONFIG_KEY_CALL: partial(
_call,
weakref.ref(t),
retry=retry_policy,
futures=weakref.ref(futures),
schedule_task=self.schedule_task,
submit=self.submit,
reraise=reraise,
),
},
)
self.commit(t, None)
@@ -255,13 +178,20 @@ class PregelRunner:
# schedule tasks
for t in tasks:
if not t.writes:
fut = self.submit(
fut = self.submit()( # type: ignore[misc]
run_with_retry,
t,
retry_policy,
configurable={
CONFIG_KEY_SEND: partial(writer, t),
CONFIG_KEY_CALL: partial(call, t),
CONFIG_KEY_CALL: partial(
_call,
weakref.ref(t),
retry=retry_policy,
futures=weakref.ref(futures),
schedule_task=self.schedule_task,
submit=self.submit,
reraise=reraise,
),
},
__reraise_on_exit__=reraise,
)
@@ -313,125 +243,10 @@ class PregelRunner:
retry_policy: Optional[RetryPolicy] = None,
get_waiter: Optional[Callable[[], asyncio.Future[None]]] = None,
) -> AsyncIterator[None]:
def writer(
task: PregelExecutableTask,
writes: Sequence[tuple[str, Any]],
*,
calls: Optional[Sequence[Call]] = None,
) -> Sequence[Optional[asyncio.Future]]:
if all(w[0] != PUSH for w in writes):
return task.config[CONF][CONFIG_KEY_SEND](writes)
# schedule PUSH tasks, collect futures
scratchpad: PregelScratchpad = task.config[CONF][CONFIG_KEY_SCRATCHPAD]
rtn: dict[int, Optional[asyncio.Future]] = {}
for idx, w in enumerate(writes):
# bail if not a PUSH write
if w[0] != PUSH:
continue
# schedule the next task, if the callback returns one
wcall = calls[idx] if calls is not None else None
if next_task := self.schedule_task(
task, scratchpad.call_counter(), wcall
):
# if the parent task was retried,
# the next task might already be running
if fut := next(
(
f
for f, t in futures.items()
if t is not None and t == next_task.id
),
None,
):
# if the parent task was retried,
# the next task might already be running
rtn[idx] = fut
elif next_task.writes:
# if it already ran, return the result
fut = asyncio.Future(loop=loop)
ret = next(
(v for c, v in next_task.writes if c == RETURN), MISSING
)
if ret is not MISSING:
fut.set_result(ret)
elif exc := next(
(v for c, v in next_task.writes if c == ERROR), None
):
fut.set_exception(
exc
if isinstance(exc, BaseException)
else Exception(exc)
)
else:
fut.set_result(None)
rtn[idx] = fut
else:
# schedule the next task
fut = cast(
asyncio.Future,
self.submit(
arun_with_retry,
next_task,
retry_policy,
stream=self.use_astream,
configurable={
CONFIG_KEY_SEND: partial(writer, next_task),
CONFIG_KEY_CALL: partial(call, next_task),
},
__name__=t.name,
__cancel_on_exit__=True,
__reraise_on_exit__=reraise,
# starting a new task in the next tick ensures
# updates from this tick are committed/streamed first
__next_tick__=True,
),
)
futures[fut] = next_task
rtn[idx] = fut
return [rtn.get(i) for i in range(len(writes))]
def call(
task: PregelExecutableTask,
func: Callable[[Any], Union[Awaitable[Any], Any]],
input: Any,
*,
retry: Optional[RetryPolicy] = None,
callbacks: Callbacks = None,
) -> Union[asyncio.Future[Any], concurrent.futures.Future[Any]]:
(fut,) = writer(
task,
[(PUSH, None)],
calls=[Call(func, input, retry=retry, callbacks=callbacks)],
)
assert fut is not None, "writer did not return a future for call"
# return a chained future to ensure commit() callback is called
# before the returned future is resolved, to ensure stream order etc
try:
in_async = asyncio.current_task() is not None
except RuntimeError:
in_async = False
# if in async context return an async future
# otherwise return a chained sync future
if in_async:
if isinstance(fut, asyncio.Task):
sfut: Union[asyncio.Future[Any], concurrent.futures.Future[Any]] = (
asyncio.Future(loop=loop)
)
loop.call_soon_threadsafe(chain_future, fut, sfut)
return sfut
else:
# already wrapped in a future
return fut
else:
sfut = concurrent.futures.Future()
loop.call_soon_threadsafe(chain_future, fut, sfut)
return sfut
loop = asyncio.get_event_loop()
tasks = tuple(tasks)
futures = FuturesDict(
callback=self.commit,
callback=weakref.WeakMethod(self.commit),
event=asyncio.Event(),
future_type=asyncio.Future,
)
@@ -446,8 +261,17 @@ class PregelRunner:
retry_policy,
stream=self.use_astream,
configurable={
CONFIG_KEY_SEND: partial(writer, t),
CONFIG_KEY_CALL: partial(call, t),
CONFIG_KEY_CALL: partial(
_acall,
weakref.ref(t),
stream=self.use_astream,
retry=retry_policy,
futures=weakref.ref(futures),
schedule_task=self.schedule_task,
submit=self.submit,
reraise=reraise,
loop=loop,
),
},
)
self.commit(t, None)
@@ -472,14 +296,23 @@ class PregelRunner:
if not t.writes:
fut = cast(
asyncio.Future,
self.submit(
self.submit()( # type: ignore[misc]
arun_with_retry,
t,
retry_policy,
stream=self.use_astream,
configurable={
CONFIG_KEY_SEND: partial(writer, t),
CONFIG_KEY_CALL: partial(call, t),
CONFIG_KEY_CALL: partial(
_acall,
weakref.ref(t),
retry=retry_policy,
stream=self.use_astream,
futures=weakref.ref(futures),
schedule_task=self.schedule_task,
submit=self.submit,
reraise=reraise,
loop=loop,
),
},
__name__=t.name,
__cancel_on_exit__=True,
@@ -539,19 +372,20 @@ class PregelRunner:
# for cancelled tasks, also save error in task,
# so loop can finish super-step
task.writes.append((ERROR, exception))
self.put_writes(task.id, task.writes)
self.put_writes()(task.id, task.writes) # type: ignore[misc]
elif exception:
if isinstance(exception, GraphInterrupt):
# save interrupt to checkpointer
if interrupts := [(INTERRUPT, i) for i in exception.args[0]]:
if exception.args[0]:
writes = [(INTERRUPT, exception.args[0])]
if resumes := [w for w in task.writes if w[0] == RESUME]:
interrupts.extend(resumes)
self.put_writes(task.id, interrupts)
writes.extend(resumes)
self.put_writes()(task.id, writes) # type: ignore[misc]
elif isinstance(exception, GraphBubbleUp):
raise exception
else:
# save error to checkpointer
self.put_writes(task.id, [(ERROR, exception)])
self.put_writes()(task.id, [(ERROR, exception)]) # type: ignore[misc]
else:
if self.node_finished and (
task.config is None or TAG_HIDDEN not in task.config.get("tags", [])
@@ -561,7 +395,7 @@ class PregelRunner:
# add no writes marker
task.writes.append((NO_WRITES, None))
# save task writes to checkpointer
self.put_writes(task.id, task.writes)
self.put_writes()(task.id, task.writes) # type: ignore[misc]
def _should_stop_others(
@@ -608,6 +442,7 @@ def _panic_or_proceed(
done.add(fut)
else:
inflight.add(fut)
interrupts: list[GraphInterrupt] = []
while done:
# if any task failed
if exc := _exception(done.pop()):
@@ -616,7 +451,14 @@ def _panic_or_proceed(
inflight.pop().cancel()
# raise the exception
if panic:
raise exc
if isinstance(exc, GraphInterrupt):
# collect interrupts
interrupts.append(exc)
else:
raise exc
# raise combined interrupts
if interrupts:
raise GraphInterrupt(tuple(i for exc in interrupts for i in exc.args[0]))
if inflight:
# if we got here means we timed out
while inflight:
@@ -624,3 +466,193 @@ def _panic_or_proceed(
inflight.pop().cancel()
# raise timeout error
raise timeout_exc_cls("Timed out")
def _call(
task: weakref.ref[PregelExecutableTask],
func: Callable[[Any], Union[Awaitable[Any], Any]],
input: Any,
*,
retry: Optional[RetryPolicy] = None,
callbacks: Callbacks = None,
futures: weakref.ref[FuturesDict],
schedule_task: weakref.ref[
Callable[
[PregelExecutableTask, int, Optional[Call]], Optional[PregelExecutableTask]
]
],
submit: weakref.ref[Submit],
reraise: bool,
) -> concurrent.futures.Future[Any]:
if asyncio.iscoroutinefunction(func):
raise RuntimeError("In an sync context async tasks cannot be called")
fut: Optional[concurrent.futures.Future] = None
# schedule PUSH tasks, collect futures
scratchpad: PregelScratchpad = task().config[CONF][CONFIG_KEY_SCRATCHPAD] # type: ignore[union-attr]
# schedule the next task, if the callback returns one
if next_task := schedule_task()( # type: ignore[misc]
task(), # type: ignore[arg-type]
scratchpad.call_counter(),
Call(func, input, retry=retry, callbacks=callbacks),
):
if fut := next(
(
f
for f, t in futures().items() # type: ignore[union-attr]
if t is not None and t == next_task.id
),
None,
):
# if the parent task was retried,
# the next task might already be running
pass
elif next_task.writes:
# if it already ran, return the result
fut = concurrent.futures.Future()
ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
if ret is not MISSING:
fut.set_result(ret)
elif exc := next((v for c, v in next_task.writes if c == ERROR), None):
fut.set_exception(
exc if isinstance(exc, BaseException) else Exception(exc)
)
else:
fut.set_result(None)
else:
# schedule the next task
fut = submit()( # type: ignore[misc]
run_with_retry,
next_task,
retry,
configurable={
CONFIG_KEY_CALL: partial(
_call,
weakref.ref(next_task),
futures=futures,
retry=retry,
callbacks=callbacks,
schedule_task=schedule_task,
submit=submit,
reraise=reraise,
),
},
__reraise_on_exit__=reraise,
# starting a new task in the next tick ensures
# updates from this tick are committed/streamed first
__next_tick__=True,
)
futures()[fut] = next_task # type: ignore[index]
fut = cast(Union[asyncio.Future, concurrent.futures.Future], fut)
# return a chained future to ensure commit() callback is called
# before the returned future is resolved, to ensure stream order etc
return chain_future(fut, concurrent.futures.Future())
def _acall(
task: weakref.ref[PregelExecutableTask],
func: Callable[[Any], Union[Awaitable[Any], Any]],
input: Any,
*,
retry: Optional[RetryPolicy] = None,
callbacks: Callbacks = None,
# injected dependencies
futures: weakref.ref[FuturesDict],
schedule_task: weakref.ref[
Callable[
[PregelExecutableTask, int, Optional[Call]], Optional[PregelExecutableTask]
]
],
submit: weakref.ref[Submit],
loop: asyncio.AbstractEventLoop,
reraise: bool = False,
stream: bool = False,
) -> Union[asyncio.Future[Any], concurrent.futures.Future[Any]]:
fut: Optional[asyncio.Future] = None
# schedule PUSH tasks, collect futures
scratchpad: PregelScratchpad = task().config[CONF][CONFIG_KEY_SCRATCHPAD] # type: ignore[union-attr]
# schedule the next task, if the callback returns one
if next_task := schedule_task()( # type: ignore[misc]
task(), # type: ignore[arg-type]
scratchpad.call_counter(),
Call(func, input, retry=retry, callbacks=callbacks),
):
if fut := next(
(
f
for f, t in futures().items() # type: ignore[union-attr]
if t is not None and t == next_task.id
),
None,
):
# if the parent task was retried,
# the next task might already be running
pass
elif next_task.writes:
# if it already ran, return the result
fut = asyncio.Future(loop=loop)
ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
if ret is not MISSING:
fut.set_result(ret)
elif exc := next((v for c, v in next_task.writes if c == ERROR), None):
fut.set_exception(
exc if isinstance(exc, BaseException) else Exception(exc)
)
else:
fut.set_result(None)
futures()[fut] = next_task # type: ignore[index]
else:
# schedule the next task
fut = cast(
asyncio.Future,
submit()( # type: ignore[misc]
arun_with_retry,
next_task,
retry,
stream=stream,
configurable={
CONFIG_KEY_CALL: partial(
_acall,
weakref.ref(next_task),
stream=stream,
futures=futures,
schedule_task=schedule_task,
submit=submit,
loop=loop,
reraise=reraise,
),
},
__name__=task().name, # type: ignore[union-attr]
__cancel_on_exit__=True,
__reraise_on_exit__=reraise,
# starting a new task in the next tick ensures
# updates from this tick are committed/streamed first
__next_tick__=True,
),
)
futures()[fut] = next_task # type: ignore[index]
fut = cast(Union[asyncio.Future, concurrent.futures.Future], fut)
# return a chained future to ensure commit() callback is called
# before the returned future is resolved, to ensure stream order etc
try:
in_async = asyncio.current_task() is not None
except RuntimeError:
in_async = False
# if in async context return an async future
# otherwise return a chained sync future
if in_async:
if isinstance(fut, asyncio.Task):
sfut: Union[asyncio.Future[Any], concurrent.futures.Future[Any]] = (
asyncio.Future(loop=loop)
)
loop.call_soon_threadsafe(chain_future, fut, sfut)
return sfut
else:
# already wrapped in a future
return fut
else:
sfut = concurrent.futures.Future()
loop.call_soon_threadsafe(chain_future, fut, sfut)
return sfut
+2
View File
@@ -7,6 +7,7 @@ from langgraph.types import (
PregelTask,
RetryPolicy,
StateSnapshot,
StateUpdate,
StreamMode,
StreamWriter,
default_retry_on,
@@ -14,6 +15,7 @@ from langgraph.types import (
__all__ = [
"All",
"StateUpdate",
"CachePolicy",
"PregelExecutableTask",
"PregelTask",
+154 -2
View File
@@ -1,7 +1,10 @@
from typing import Optional
import ast
import inspect
import textwrap
from typing import Any, Callable, Optional
from langchain_core.runnables import RunnableLambda, RunnableSequence
from langchain_core.runnables.utils import get_function_nonlocals
from typing_extensions import override
from langgraph.checkpoint.base import ChannelVersions
from langgraph.pregel.protocol import PregelProtocol
@@ -55,3 +58,152 @@ def find_subgraph_pregel(candidate: Runnable) -> Optional[PregelProtocol]:
)
return None
def get_function_nonlocals(func: Callable) -> list[Any]:
"""Get the nonlocal variables accessed by a function.
Args:
func: The function to check.
Returns:
List[Any]: The nonlocal variables accessed by the function.
"""
try:
code = inspect.getsource(func)
tree = ast.parse(textwrap.dedent(code))
visitor = FunctionNonLocals()
visitor.visit(tree)
values: list[Any] = []
closure = (
inspect.getclosurevars(func.__wrapped__)
if hasattr(func, "__wrapped__") and callable(func.__wrapped__)
else inspect.getclosurevars(func)
)
candidates = {**closure.globals, **closure.nonlocals}
for k, v in candidates.items():
if k in visitor.nonlocals:
values.append(v)
for kk in visitor.nonlocals:
if "." in kk and kk.startswith(k):
vv = v
for part in kk.split(".")[1:]:
if vv is None:
break
else:
try:
vv = getattr(vv, part)
except AttributeError:
break
else:
values.append(vv)
except (SyntaxError, TypeError, OSError, SystemError):
return []
return values
class FunctionNonLocals(ast.NodeVisitor):
"""Get the nonlocal variables accessed of a function."""
def __init__(self) -> None:
self.nonlocals: set[str] = set()
@override
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
"""Visit a function definition.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
visitor = NonLocals()
visitor.visit(node)
self.nonlocals.update(visitor.loads - visitor.stores)
@override
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any:
"""Visit an async function definition.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
visitor = NonLocals()
visitor.visit(node)
self.nonlocals.update(visitor.loads - visitor.stores)
@override
def visit_Lambda(self, node: ast.Lambda) -> Any:
"""Visit a lambda function.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
visitor = NonLocals()
visitor.visit(node)
self.nonlocals.update(visitor.loads - visitor.stores)
class NonLocals(ast.NodeVisitor):
"""Get nonlocal variables accessed."""
def __init__(self) -> None:
self.loads: set[str] = set()
self.stores: set[str] = set()
@override
def visit_Name(self, node: ast.Name) -> Any:
"""Visit a name node.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
if isinstance(node.ctx, ast.Load):
self.loads.add(node.id)
elif isinstance(node.ctx, ast.Store):
self.stores.add(node.id)
@override
def visit_Attribute(self, node: ast.Attribute) -> Any:
"""Visit an attribute node.
Args:
node: The node to visit.
Returns:
Any: The result of the visit.
"""
if isinstance(node.ctx, ast.Load):
parent = node.value
attr_expr = node.attr
while isinstance(parent, ast.Attribute):
attr_expr = parent.attr + "." + attr_expr
parent = parent.value
if isinstance(parent, ast.Name):
self.loads.add(parent.id + "." + attr_expr)
self.loads.discard(parent.id)
elif isinstance(parent, ast.Call):
if isinstance(parent.func, ast.Name):
self.loads.add(parent.func.id)
else:
parent = parent.func
attr_expr = ""
while isinstance(parent, ast.Attribute):
if attr_expr:
attr_expr = parent.attr + "." + attr_expr
else:
attr_expr = parent.attr
parent = parent.value
if isinstance(parent, ast.Name):
self.loads.add(parent.id + "." + attr_expr)
+9 -14
View File
@@ -49,21 +49,24 @@ class ChannelWrite(RunnableCallable):
writes: list[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]]
"""Sequence of write entries or Send objects to write."""
require_at_least_one_of: Optional[Sequence[str]]
"""If defined, at least one of these channels must be written to."""
def __init__(
self,
writes: Sequence[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]],
*,
tags: Optional[Sequence[str]] = None,
require_at_least_one_of: Optional[Sequence[str]] = None,
require_at_least_one_of: Optional[Sequence[str]] = None, # ignored
):
super().__init__(func=self._write, afunc=self._awrite, name=None, tags=tags)
super().__init__(
func=self._write,
afunc=self._awrite,
name=None,
tags=tags,
func_accepts_config=True,
)
self.writes = cast(
list[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]], writes
)
self.require_at_least_one_of = require_at_least_one_of
def get_name(
self, suffix: Optional[str] = None, *, name: Optional[str] = None
@@ -96,7 +99,6 @@ class ChannelWrite(RunnableCallable):
self.do_write(
config,
writes,
self.require_at_least_one_of if input is not None else None,
)
return input
@@ -112,7 +114,6 @@ class ChannelWrite(RunnableCallable):
self.do_write(
config,
writes,
self.require_at_least_one_of if input is not None else None,
)
return input
@@ -120,7 +121,7 @@ class ChannelWrite(RunnableCallable):
def do_write(
config: RunnableConfig,
writes: Sequence[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]],
require_at_least_one_of: Optional[Sequence[str]] = None,
require_at_least_one_of: Optional[Sequence[str]] = None, # ignored
) -> None:
# validate
for w in writes:
@@ -151,12 +152,6 @@ class ChannelWrite(RunnableCallable):
tuples.append((w.channel, value))
else:
raise ValueError(f"Invalid write entry: {w}")
# assert required channels
if require_at_least_one_of is not None:
if not {chan for chan, _ in tuples} & set(require_at_least_one_of):
raise InvalidUpdateError(
f"Must write to at least one of {require_at_least_one_of}"
)
write: TYPE_SEND = config[CONF][CONFIG_KEY_SEND]
write(tuples)
+19 -16
View File
@@ -130,7 +130,12 @@ class Interrupt:
value: Any
resumable: bool = False
ns: Optional[Sequence[str]] = None
when: Literal["during"] = "during"
when: Literal["during"] = dataclasses.field(default="during", repr=False)
class StateUpdate(NamedTuple):
values: Optional[dict[str, Any]]
as_node: Optional[str] = None
class PregelTask(NamedTuple):
@@ -140,16 +145,23 @@ class PregelTask(NamedTuple):
error: Optional[Exception] = None
interrupts: tuple[Interrupt, ...] = ()
state: Union[None, RunnableConfig, "StateSnapshot"] = None
result: Optional[dict[str, Any]] = None
result: Optional[Any] = None
class PregelExecutableTask(NamedTuple):
if sys.version_info > (3, 11):
_T_DC_KWARGS = {"weakref_slot": True, "slots": True, "frozen": True}
else:
_T_DC_KWARGS = {"frozen": True}
@dataclasses.dataclass(**_T_DC_KWARGS)
class PregelExecutableTask:
name: str
input: Any
proc: Runnable
writes: deque[tuple[str, Any]]
config: RunnableConfig
triggers: list[str]
triggers: Sequence[str]
retry_policy: Optional[RetryPolicy]
cache_policy: Optional[CachePolicy]
id: str
@@ -351,20 +363,11 @@ class PregelScratchpad:
call_counter: Callable[[], int]
# interrupt
interrupt_counter: Callable[[], int]
get_null_resume: Callable[[bool], Any]
resume: list[Any]
null_resume: Optional[Any]
_consume_null_resume: Callable[[], None]
# subgraph
subgraph_counter: Callable[[], int]
def consume_null_resume(self) -> Any:
if self.null_resume is not None:
value = self.null_resume
self._consume_null_resume()
self.null_resume = None
return value
raise ValueError("No null resume to consume")
def interrupt(value: Any) -> Any:
"""Interrupt the graph with a resumable exception from within a node.
@@ -480,9 +483,9 @@ def interrupt(value: Any) -> Any:
if idx < len(scratchpad.resume):
return scratchpad.resume[idx]
# find current resume value
if scratchpad.null_resume is not None:
v = scratchpad.get_null_resume(True)
if v is not None:
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
v = scratchpad.consume_null_resume()
scratchpad.resume.append(v)
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return v
+3 -1
View File
@@ -1,4 +1,5 @@
from collections import ChainMap
from os import getenv
from typing import Any, Optional, Sequence, cast
from langchain_core.callbacks import (
@@ -11,7 +12,6 @@ from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.config import (
CONFIG_KEYS,
COPIABLE_KEYS,
DEFAULT_RECURSION_LIMIT,
var_child_runnable_config,
)
@@ -26,6 +26,8 @@ from langgraph.constants import (
NS_SEP,
)
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "25"))
def recast_checkpoint_ns(ns: str) -> str:
"""Remove task IDs from checkpoint namespace.
+104 -44
View File
@@ -2,8 +2,8 @@ import asyncio
import enum
import inspect
import sys
from contextlib import AsyncExitStack
from contextvars import copy_context
from contextlib import AsyncExitStack, contextmanager
from contextvars import Context, Token, copy_context
from functools import partial, wraps
from typing import (
Any,
@@ -11,6 +11,7 @@ from typing import (
Awaitable,
Callable,
Coroutine,
Generator,
Iterator,
Optional,
Protocol,
@@ -53,13 +54,69 @@ from langgraph.utils.config import (
patch_config,
)
try:
from langchain_core.runnables.config import _set_config_context
except ImportError:
# For forwards compatibility
def _set_config_context(context: RunnableConfig) -> None: # type: ignore
"""Set the context for the current thread."""
var_child_runnable_config.set(context)
def _set_config_context(
config: RunnableConfig,
) -> tuple[Token[Optional[RunnableConfig]], Optional[dict[str, Any]]]:
"""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
current_context = get_tracing_context()
_set_tracing_context({"parent": run})
return config_token, current_context
@contextmanager
def set_config_context(config: RunnableConfig) -> Generator[Context, None, None]:
"""Set the child Runnable config + tracing context.
Args:
config (RunnableConfig): The config to set.
"""
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,
{
"parent": None,
"project_name": None,
"tags": None,
"metadata": None,
"enabled": None,
"client": None,
},
)
# Before Python 3.11 native StrEnum is not available
@@ -194,6 +251,7 @@ class RunnableCallable(Runnable):
trace: bool = True,
recurse: bool = True,
explode_args: bool = False,
func_accepts_config: Optional[bool] = None,
**kwargs: Any,
) -> None:
self.name = name
@@ -219,27 +277,32 @@ class RunnableCallable(Runnable):
# check signature
if func is None and afunc is None:
raise ValueError("At least one of func or afunc must be provided.")
params = inspect.signature(cast(Callable, func or afunc)).parameters
self.func_accepts_config = "config" in params
# Mapping from kwarg name to (config key, default value) to be used.
# The default value is used if the config key is not found in the config.
self.func_accepts: dict[str, Tuple[str, Any]] = {}
if func_accepts_config is not None:
self.func_accepts_config = func_accepts_config
self.func_accepts: dict[str, Tuple[str, Any]] = {}
else:
params = inspect.signature(cast(Callable, func or afunc)).parameters
for kw, typ, config_key, default in KWARGS_CONFIG_KEYS:
p = params.get(kw)
self.func_accepts_config = "config" in params
# Mapping from kwarg name to (config key, default value) to be used.
# The default value is used if the config key is not found in the config.
self.func_accepts = {}
if p is None or p.kind not in VALID_KINDS:
# If parameter is not found or is not a valid kind, skip
continue
for kw, typ, config_key, default in KWARGS_CONFIG_KEYS:
p = params.get(kw)
if typ != (ANY_TYPE,) and p.annotation not in typ:
# A specific type is required, but the function annotation does
# not match the expected type.
continue
if p is None or p.kind not in VALID_KINDS:
# If parameter is not found or is not a valid kind, skip
continue
# If the kwarg is accepted by the function, store the default value
self.func_accepts[kw] = (config_key, default)
if typ != (ANY_TYPE,) and p.annotation not in typ:
# A specific type is required, but the function annotation does
# not match the expected type.
continue
# If the kwarg is accepted by the function, store the default value
self.func_accepts[kw] = (config_key, default)
def __repr__(self) -> str:
repr_args = {
@@ -286,7 +349,6 @@ class RunnableCallable(Runnable):
kwargs[kw] = _conf.get(config_key, default_value)
context = copy_context()
if self.trace:
callback_manager = get_callback_manager_for_config(config, self.tags)
run_manager = callback_manager.on_chain_start(
@@ -297,17 +359,16 @@ class RunnableCallable(Runnable):
)
try:
child_config = patch_config(config, callbacks=run_manager.get_child())
context = copy_context()
context.run(_set_config_context, child_config)
ret = context.run(self.func, *args, **kwargs)
with set_config_context(child_config) as context:
ret = context.run(self.func, *args, **kwargs)
except BaseException as e:
run_manager.on_chain_error(e)
raise
else:
run_manager.on_chain_end(ret)
else:
context.run(_set_config_context, config)
ret = context.run(self.func, *args, **kwargs)
with set_config_context(config) as context:
ret = context.run(self.func, *args, **kwargs)
if isinstance(ret, Runnable) and self.recurse:
return ret.invoke(input, config)
return ret
@@ -342,7 +403,6 @@ class RunnableCallable(Runnable):
f"Missing required config key '{config_key}' for '{self.name}'."
)
kwargs[kw] = _conf.get(config_key, default_value)
context = copy_context()
if self.trace:
callback_manager = get_async_callback_manager_for_config(config, self.tags)
run_manager = await callback_manager.on_chain_start(
@@ -353,24 +413,24 @@ class RunnableCallable(Runnable):
)
try:
child_config = patch_config(config, callbacks=run_manager.get_child())
context.run(_set_config_context, child_config)
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
if ASYNCIO_ACCEPTS_CONTEXT:
ret = await asyncio.create_task(coro, context=context)
else:
ret = await coro
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)
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:
context.run(_set_config_context, config)
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)
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:
return await ret.ainvoke(input, config)
return ret
+51 -9
View File
@@ -1324,14 +1324,14 @@ files = [
[[package]]
name = "langchain-core"
version = "0.3.44"
version = "0.3.46"
description = "Building applications with LLMs through composability"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main", "dev"]
files = [
{file = "langchain_core-0.3.44-py3-none-any.whl", hash = "sha256:d989ce8bd62f1d07765acd575e6ec1254aec0cf7775aaea39fe4af8102377459"},
{file = "langchain_core-0.3.44.tar.gz", hash = "sha256:7c0a01e78360f007cbca448178fe7e032404068e6431dbe8ce905f84febbdfa5"},
{file = "langchain_core-0.3.46-py3-none-any.whl", hash = "sha256:28b5689fc347975ea520b5364ab4aee5567e661553bbee5e97cabf4596c28ce0"},
{file = "langchain_core-0.3.46.tar.gz", hash = "sha256:5fca010eeb0a427be5aa8a8525e2112995dde790c584cef165be7c5e0ee1c2b5"},
]
[package.dependencies]
@@ -1348,7 +1348,7 @@ typing-extensions = ">=4.7"
[[package]]
name = "langgraph-checkpoint"
version = "2.0.18"
version = "2.0.21"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1366,7 +1366,7 @@ url = "../checkpoint"
[[package]]
name = "langgraph-checkpoint-postgres"
version = "2.0.16"
version = "2.0.19"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1375,7 +1375,7 @@ files = []
develop = true
[package.dependencies]
langgraph-checkpoint = "^2.0.15"
langgraph-checkpoint = "^2.0.21"
orjson = ">=3.10.1"
psycopg = "^3.2.0"
psycopg-pool = "^3.2.0"
@@ -1404,7 +1404,7 @@ url = "../checkpoint-sqlite"
[[package]]
name = "langgraph-prebuilt"
version = "0.1.2"
version = "0.1.4"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1422,7 +1422,7 @@ url = "../prebuilt"
[[package]]
name = "langgraph-sdk"
version = "0.1.55"
version = "0.1.58"
description = "SDK for interacting with LangGraph API"
optional = false
python-versions = "^3.9.0,<4.0"
@@ -2238,6 +2238,48 @@ files = [
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
[[package]]
name = "pycryptodome"
version = "3.21.0"
description = "Cryptographic library for Python"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
groups = ["dev"]
files = [
{file = "pycryptodome-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dad9bf36eda068e89059d1f07408e397856be9511d7113ea4b586642a429a4fd"},
{file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a1752eca64c60852f38bb29e2c86fca30d7672c024128ef5d70cc15868fa10f4"},
{file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ba4cc304eac4d4d458f508d4955a88ba25026890e8abff9b60404f76a62c55e"},
{file = "pycryptodome-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cb087b8612c8a1a14cf37dd754685be9a8d9869bed2ffaaceb04850a8aeef7e"},
{file = "pycryptodome-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:26412b21df30b2861424a6c6d5b1d8ca8107612a4cfa4d0183e71c5d200fb34a"},
{file = "pycryptodome-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:cc2269ab4bce40b027b49663d61d816903a4bd90ad88cb99ed561aadb3888dd3"},
{file = "pycryptodome-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0fa0a05a6a697ccbf2a12cec3d6d2650b50881899b845fac6e87416f8cb7e87d"},
{file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6cce52e196a5f1d6797ff7946cdff2038d3b5f0aba4a43cb6bf46b575fd1b5bb"},
{file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:a915597ffccabe902e7090e199a7bf7a381c5506a747d5e9d27ba55197a2c568"},
{file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e74c522d630766b03a836c15bff77cb657c5fdf098abf8b1ada2aebc7d0819"},
{file = "pycryptodome-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:a3804675283f4764a02db05f5191eb8fec2bb6ca34d466167fc78a5f05bbe6b3"},
{file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4"},
{file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b"},
{file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e"},
{file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8"},
{file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1"},
{file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a"},
{file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2"},
{file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93"},
{file = "pycryptodome-3.21.0-cp36-abi3-win32.whl", hash = "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764"},
{file = "pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53"},
{file = "pycryptodome-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:2cb635b67011bc147c257e61ce864879ffe6d03342dc74b6045059dfbdedafca"},
{file = "pycryptodome-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:4c26a2f0dc15f81ea3afa3b0c87b87e501f235d332b7f27e2225ecb80c0b1cdd"},
{file = "pycryptodome-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8"},
{file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6"},
{file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0"},
{file = "pycryptodome-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6"},
{file = "pycryptodome-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:590ef0898a4b0a15485b05210b4a1c9de8806d3ad3d47f74ab1dc07c67a6827f"},
{file = "pycryptodome-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35e442630bc4bc2e1878482d6f59ea22e280d7121d7adeaedba58c23ab6386b"},
{file = "pycryptodome-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff99f952db3db2fbe98a0b355175f93ec334ba3d01bbde25ad3a5a33abc02b58"},
{file = "pycryptodome-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8acd7d34af70ee63f9a849f957558e49a98f8f1634f86a59d2be62bb8e93f71c"},
{file = "pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297"},
]
[[package]]
name = "pydantic"
version = "2.9.2"
@@ -3509,4 +3551,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9.0,<4.0"
content-hash = "eb85f0bcc0e8a715ef38afb58cf888f7c2ee8579ea6ed94900244365f24cddd9"
content-hash = "b8641a0b2d92bee0363602e69f99b23366b2035b7e17ff017708194e6fbd0ac5"
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.3.7"
version = "0.3.18"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
license = "MIT"
@@ -37,6 +37,7 @@ uvloop = "0.21.0beta1"
pyperf = "^2.7.0"
py-spy = "^0.3.14"
types-requests = "^2.32.0.20240914"
pycryptodome = "^3.21.0"
[tool.ruff]
lint.select = [ "E", "F", "I", "TID251" ]
File diff suppressed because one or more lines are too long
@@ -377,6 +377,19 @@
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge[sqlite_aes]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query --> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[memory]
'''
graph TD;
@@ -797,6 +810,76 @@
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[sqlite_aes]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[sqlite_aes].1
dict({
'definitions': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/definitions/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1[sqlite_aes].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[memory]
'''
graph TD;
@@ -1217,6 +1300,76 @@
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[sqlite_aes]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[sqlite_aes].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2[sqlite_aes].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[memory]
'''
graph TD;
@@ -1715,6 +1868,19 @@
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_via_branch[sqlite_aes]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_multiple_sinks_subgraphs
'''
%%{init: {'flowchart': {'curve': 'linear'}}}%%
+11
View File
@@ -16,6 +16,7 @@ from langgraph.checkpoint.postgres.aio import (
AsyncPostgresSaver,
AsyncShallowPostgresSaver,
)
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.store.base import BaseStore
@@ -61,6 +62,15 @@ def checkpointer_sqlite():
yield checkpointer
@pytest.fixture(scope="function")
def checkpointer_sqlite_aes():
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
checkpointer.serde = EncryptedSerializer.from_pycryptodome_aes(
key=b"1234567890123456"
)
yield checkpointer
@asynccontextmanager
async def _checkpointer_sqlite_aio():
async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
@@ -437,6 +447,7 @@ REGULAR_CHECKPOINTERS_SYNC = [
"postgres",
"postgres_pipe",
"postgres_pool",
"sqlite_aes",
]
ALL_CHECKPOINTERS_SYNC = [
*REGULAR_CHECKPOINTERS_SYNC,
+2 -59
View File
@@ -1,4 +1,3 @@
import asyncio
import os
import tempfile
from collections import defaultdict
@@ -13,7 +12,6 @@ from langgraph.checkpoint.base import (
CheckpointMetadata,
CheckpointTuple,
SerializerProtocol,
copy_checkpoint,
)
from langgraph.checkpoint.memory import InMemorySaver, PersistentDict
@@ -63,69 +61,14 @@ class MemorySaverAssertImmutable(InMemorySaver):
self.storage_for_copies[thread_id][checkpoint_ns][saved["id"]]
)
== saved
)
), config["configurable"]["checkpoint_ns"]
self.storage_for_copies[thread_id][checkpoint_ns][checkpoint["id"]] = (
self.serde.dumps_typed(copy_checkpoint(checkpoint))
self.serde.dumps_typed(checkpoint)
)
# call super to write checkpoint
return super().put(config, checkpoint, metadata, new_versions)
class MemorySaverAssertCheckpointMetadata(InMemorySaver):
"""This custom checkpointer is for verifying that a run's configurable
fields are merged with the previous checkpoint config for each step in
the run. This is the desired behavior. Because the checkpointer's (a)put()
method is called for each step, the implementation of this checkpointer
should produce a side effect that can be asserted.
"""
def put(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> None:
"""The implementation of put() merges config["configurable"] (a run's
configurable fields) with the metadata field. The state of the
checkpoint metadata can be asserted to confirm that the run's
configurable fields were merged with the previous checkpoint config.
"""
configurable = config["configurable"].copy()
# remove checkpoint_id to make testing simpler
checkpoint_id = configurable.pop("checkpoint_id", None)
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"]
self.storage[thread_id][checkpoint_ns].update(
{
checkpoint["id"]: (
self.serde.dumps_typed(checkpoint),
# merge configurable fields and metadata
self.serde.dumps_typed({**configurable, **metadata}),
checkpoint_id,
)
}
)
return {
"configurable": {
"thread_id": config["configurable"]["thread_id"],
"checkpoint_id": checkpoint["id"],
}
}
async def aput(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
return await asyncio.get_running_loop().run_in_executor(
None, self.put, config, checkpoint, metadata, new_versions
)
class MemorySaverNoPending(InMemorySaver):
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
result = super().get_tuple(config)
+44 -33
View File
@@ -2483,7 +2483,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
{
"langgraph_step": 1,
"langgraph_node": "agent",
"langgraph_triggers": ["start:agent"],
"langgraph_triggers": ("branch:to:agent", "start:agent", "tools"),
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
@@ -2500,7 +2500,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
{
"langgraph_step": 2,
"langgraph_node": "tools",
"langgraph_triggers": ["branch:agent:should_continue:tools"],
"langgraph_triggers": ("branch:to:tools",),
"langgraph_path": (PULL, "tools"),
"langgraph_checkpoint_ns": AnyStr("tools:"),
},
@@ -2542,7 +2542,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
{
"langgraph_step": 3,
"langgraph_node": "agent",
"langgraph_triggers": ["tools"],
"langgraph_triggers": ("branch:to:agent", "start:agent", "tools"),
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
@@ -2559,7 +2559,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
{
"langgraph_step": 4,
"langgraph_node": "tools",
"langgraph_triggers": ["branch:agent:should_continue:tools"],
"langgraph_triggers": ("branch:to:tools",),
"langgraph_path": (PULL, "tools"),
"langgraph_checkpoint_ns": AnyStr("tools:"),
},
@@ -2573,7 +2573,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
{
"langgraph_step": 4,
"langgraph_node": "tools",
"langgraph_triggers": ["branch:agent:should_continue:tools"],
"langgraph_triggers": ("branch:to:tools",),
"langgraph_path": (PULL, "tools"),
"langgraph_checkpoint_ns": AnyStr("tools:"),
},
@@ -2585,7 +2585,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
{
"langgraph_step": 5,
"langgraph_node": "agent",
"langgraph_triggers": ["tools"],
"langgraph_triggers": ("branch:to:agent", "start:agent", "tools"),
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
@@ -5501,7 +5501,10 @@ def test_in_one_fan_out_out_one_graph_state() -> None:
"id": AnyStr(),
"name": "rewrite_query",
"input": {"query": "what is weather in sf", "docs": []},
"triggers": ["start:rewrite_query"],
"triggers": (
"branch:to:rewrite_query",
"start:rewrite_query",
),
},
},
),
@@ -5532,7 +5535,10 @@ def test_in_one_fan_out_out_one_graph_state() -> None:
"id": AnyStr(),
"name": "retriever_one",
"input": {"query": "query: what is weather in sf", "docs": []},
"triggers": ["rewrite_query"],
"triggers": (
"branch:to:retriever_one",
"rewrite_query",
),
},
},
),
@@ -5546,7 +5552,10 @@ def test_in_one_fan_out_out_one_graph_state() -> None:
"id": AnyStr(),
"name": "retriever_two",
"input": {"query": "query: what is weather in sf", "docs": []},
"triggers": ["rewrite_query"],
"triggers": (
"branch:to:retriever_two",
"rewrite_query",
),
},
},
),
@@ -5608,7 +5617,7 @@ def test_in_one_fan_out_out_one_graph_state() -> None:
"query": "query: what is weather in sf",
"docs": ["doc1", "doc2", "doc3", "doc4"],
},
"triggers": ["retriever_one", "retriever_two"],
"triggers": ("branch:to:qa", "retriever_one", "retriever_two"),
},
},
),
@@ -6634,7 +6643,7 @@ def test_branch_then(
"id": AnyStr(),
"name": "prepare",
"input": {"my_key": "value", "market": "DE"},
"triggers": ["start:prepare"],
"triggers": ("branch:to:prepare", "start:prepare"),
},
},
{
@@ -6706,7 +6715,7 @@ def test_branch_then(
"id": AnyStr(),
"name": "tool_two_slow",
"input": {"my_key": "value prepared", "market": "DE"},
"triggers": ["branch:prepare:condition:tool_two_slow"],
"triggers": ("branch:to:tool_two_slow",),
},
},
{
@@ -6773,7 +6782,10 @@ def test_branch_then(
"id": AnyStr(),
"name": "finish",
"input": {"my_key": "value prepared slow", "market": "DE"},
"triggers": ["branch:prepare:condition::then"],
"triggers": (
"branch:prepare:condition::then",
"branch:to:finish",
),
},
},
{
@@ -7783,7 +7795,7 @@ def test_nested_graph_state(
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -7978,7 +7990,7 @@ def test_nested_graph_state(
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -8021,7 +8033,7 @@ def test_nested_graph_state(
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -8070,7 +8082,7 @@ def test_nested_graph_state(
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -8504,7 +8516,7 @@ def test_doubly_nested_graph_state(
"langgraph_node": "child_1",
"langgraph_path": [PULL, AnyStr("child_1")],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")],
},
created_at=AnyStr(),
parent_config=(
@@ -8588,7 +8600,10 @@ def test_doubly_nested_graph_state(
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": [
"branch:to:child_1",
AnyStr("start:child_1"),
],
},
created_at=AnyStr(),
parent_config=(
@@ -8635,7 +8650,7 @@ def test_doubly_nested_graph_state(
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -8931,7 +8946,7 @@ def test_doubly_nested_graph_state(
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -8970,7 +8985,7 @@ def test_doubly_nested_graph_state(
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -9022,7 +9037,7 @@ def test_doubly_nested_graph_state(
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -9076,7 +9091,7 @@ def test_doubly_nested_graph_state(
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")],
},
created_at=AnyStr(),
parent_config={
@@ -9131,7 +9146,7 @@ def test_doubly_nested_graph_state(
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")],
},
created_at=AnyStr(),
parent_config={
@@ -9193,7 +9208,7 @@ def test_doubly_nested_graph_state(
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")],
},
created_at=AnyStr(),
parent_config={
@@ -9255,7 +9270,7 @@ def test_doubly_nested_graph_state(
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")],
},
created_at=AnyStr(),
parent_config=None,
@@ -10378,9 +10393,7 @@ def test_weather_subgraph(
"langgraph_node": "weather_graph",
"langgraph_path": [PULL, "weather_graph"],
"langgraph_step": 2,
"langgraph_triggers": [
"branch:router_node:route_after_prediction:weather_graph"
],
"langgraph_triggers": ["branch:to:weather_graph"],
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
},
created_at=AnyStr(),
@@ -10492,9 +10505,7 @@ def test_weather_subgraph(
"langgraph_node": "weather_graph",
"langgraph_path": [PULL, "weather_graph"],
"langgraph_step": 2,
"langgraph_triggers": [
"branch:router_node:route_after_prediction:weather_graph"
],
"langgraph_triggers": ["branch:to:weather_graph"],
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
},
created_at=AnyStr(),
+78 -34
View File
@@ -2300,7 +2300,11 @@ async def test_prebuilt_tool_chat() -> None:
{
"langgraph_step": 1,
"langgraph_node": "agent",
"langgraph_triggers": ["start:agent"],
"langgraph_triggers": (
"branch:to:agent",
"start:agent",
"tools",
),
"langgraph_path": ("__pregel_pull", "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
@@ -2317,7 +2321,7 @@ async def test_prebuilt_tool_chat() -> None:
{
"langgraph_step": 2,
"langgraph_node": "tools",
"langgraph_triggers": ["branch:agent:should_continue:tools"],
"langgraph_triggers": ("branch:to:tools",),
"langgraph_path": ("__pregel_pull", "tools"),
"langgraph_checkpoint_ns": AnyStr("tools:"),
},
@@ -2359,7 +2363,11 @@ async def test_prebuilt_tool_chat() -> None:
{
"langgraph_step": 3,
"langgraph_node": "agent",
"langgraph_triggers": ["tools"],
"langgraph_triggers": (
"branch:to:agent",
"start:agent",
"tools",
),
"langgraph_path": ("__pregel_pull", "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
@@ -2376,7 +2384,7 @@ async def test_prebuilt_tool_chat() -> None:
{
"langgraph_step": 4,
"langgraph_node": "tools",
"langgraph_triggers": ["branch:agent:should_continue:tools"],
"langgraph_triggers": ("branch:to:tools",),
"langgraph_path": ("__pregel_pull", "tools"),
"langgraph_checkpoint_ns": AnyStr("tools:"),
},
@@ -2390,7 +2398,7 @@ async def test_prebuilt_tool_chat() -> None:
{
"langgraph_step": 4,
"langgraph_node": "tools",
"langgraph_triggers": ["branch:agent:should_continue:tools"],
"langgraph_triggers": ("branch:to:tools",),
"langgraph_path": ("__pregel_pull", "tools"),
"langgraph_checkpoint_ns": AnyStr("tools:"),
},
@@ -2402,7 +2410,11 @@ async def test_prebuilt_tool_chat() -> None:
{
"langgraph_step": 5,
"langgraph_node": "agent",
"langgraph_triggers": ["tools"],
"langgraph_triggers": (
"branch:to:agent",
"start:agent",
"tools",
),
"langgraph_path": ("__pregel_pull", "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
@@ -3883,7 +3895,10 @@ async def test_in_one_fan_out_out_one_graph_state() -> None:
"id": AnyStr(),
"name": "rewrite_query",
"input": {"query": "what is weather in sf", "docs": []},
"triggers": ["start:rewrite_query"],
"triggers": (
"branch:to:rewrite_query",
"start:rewrite_query",
),
},
},
),
@@ -3914,7 +3929,10 @@ async def test_in_one_fan_out_out_one_graph_state() -> None:
"id": AnyStr(),
"name": "retriever_one",
"input": {"query": "query: what is weather in sf", "docs": []},
"triggers": ["rewrite_query"],
"triggers": (
"branch:to:retriever_one",
"rewrite_query",
),
},
},
),
@@ -3928,7 +3946,10 @@ async def test_in_one_fan_out_out_one_graph_state() -> None:
"id": AnyStr(),
"name": "retriever_two",
"input": {"query": "query: what is weather in sf", "docs": []},
"triggers": ["rewrite_query"],
"triggers": (
"branch:to:retriever_two",
"rewrite_query",
),
},
},
),
@@ -3990,7 +4011,7 @@ async def test_in_one_fan_out_out_one_graph_state() -> None:
"query": "query: what is weather in sf",
"docs": ["doc1", "doc2", "doc3", "doc4"],
},
"triggers": ["retriever_one", "retriever_two"],
"triggers": ("branch:to:qa", "retriever_one", "retriever_two"),
},
},
),
@@ -4465,7 +4486,10 @@ async def test_branch_then(checkpointer_name: str) -> None:
"id": AnyStr(),
"name": "prepare",
"input": {"my_key": "value", "market": "DE"},
"triggers": ["start:prepare"],
"triggers": (
"branch:to:prepare",
"start:prepare",
),
},
},
{
@@ -4537,7 +4561,7 @@ async def test_branch_then(checkpointer_name: str) -> None:
"id": AnyStr(),
"name": "tool_two_slow",
"input": {"my_key": "value prepared", "market": "DE"},
"triggers": ["branch:prepare:condition:tool_two_slow"],
"triggers": ("branch:to:tool_two_slow",),
},
},
{
@@ -4609,7 +4633,10 @@ async def test_branch_then(checkpointer_name: str) -> None:
"id": AnyStr(),
"name": "finish",
"input": {"my_key": "value prepared slow", "market": "DE"},
"triggers": ["branch:prepare:condition::then"],
"triggers": (
"branch:prepare:condition::then",
"branch:to:finish",
),
},
},
{
@@ -4778,7 +4805,10 @@ async def test_branch_then(checkpointer_name: str) -> None:
"id": AnyStr(),
"name": "prepare",
"input": {"my_key": "value", "market": "DE"},
"triggers": ["start:prepare"],
"triggers": (
"branch:to:prepare",
"start:prepare",
),
},
},
{
@@ -5333,7 +5363,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -5530,7 +5560,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -5573,7 +5603,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -5622,7 +5652,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "inner",
"langgraph_path": [PULL, "inner"],
"langgraph_step": 2,
"langgraph_triggers": ["outer_1"],
"langgraph_triggers": ["branch:to:inner", "outer_1"],
"langgraph_checkpoint_ns": AnyStr("inner:"),
},
created_at=AnyStr(),
@@ -6060,7 +6090,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "child_1",
"langgraph_path": [PULL, AnyStr("child_1")],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": ["branch:to:child_1", "start:child_1"],
},
created_at=AnyStr(),
parent_config=(
@@ -6146,7 +6176,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": [
"branch:to:child_1",
"start:child_1",
],
},
created_at=AnyStr(),
parent_config=(
@@ -6195,7 +6228,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": [
"branch:to:child",
AnyStr("parent_1"),
],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -6493,7 +6529,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -6532,7 +6568,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -6584,7 +6620,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
"langgraph_node": "child",
"langgraph_path": [PULL, AnyStr("child")],
"langgraph_step": 2,
"langgraph_triggers": [AnyStr("parent_1")],
"langgraph_triggers": ["branch:to:child", AnyStr("parent_1")],
"langgraph_checkpoint_ns": AnyStr("child:"),
},
created_at=AnyStr(),
@@ -6642,7 +6678,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": [
"branch:to:child_1",
AnyStr("start:child_1"),
],
},
created_at=AnyStr(),
parent_config={
@@ -6697,7 +6736,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": [
"branch:to:child_1",
AnyStr("start:child_1"),
],
},
created_at=AnyStr(),
parent_config={
@@ -6759,7 +6801,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": [
"branch:to:child_1",
AnyStr("start:child_1"),
],
},
created_at=AnyStr(),
parent_config={
@@ -6821,7 +6866,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None:
AnyStr("child_1"),
],
"langgraph_step": 1,
"langgraph_triggers": [AnyStr("start:child_1")],
"langgraph_triggers": [
"branch:to:child_1",
AnyStr("start:child_1"),
],
},
created_at=AnyStr(),
parent_config=None,
@@ -7231,9 +7279,7 @@ async def test_weather_subgraph(
"langgraph_node": "weather_graph",
"langgraph_path": [PULL, "weather_graph"],
"langgraph_step": 2,
"langgraph_triggers": [
"branch:router_node:route_after_prediction:weather_graph"
],
"langgraph_triggers": ["branch:to:weather_graph"],
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
},
created_at=AnyStr(),
@@ -7347,9 +7393,7 @@ async def test_weather_subgraph(
"langgraph_node": "weather_graph",
"langgraph_path": [PULL, "weather_graph"],
"langgraph_step": 2,
"langgraph_triggers": [
"branch:router_node:route_after_prediction:weather_graph"
],
"langgraph_triggers": ["branch:to:weather_graph"],
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
},
created_at=AnyStr(),
File diff suppressed because it is too large Load Diff
+485 -57
View File
@@ -1,5 +1,7 @@
import asyncio
import enum
import functools
import gc
import logging
import operator
import random
@@ -27,11 +29,7 @@ from uuid import UUID
import httpx
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.runnables import (
RunnableConfig,
RunnableLambda,
RunnablePassthrough,
)
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
from langchain_core.utils.aiter import aclosing
from pytest_mock import MockerFixture
from syrupy import SnapshotAssertion
@@ -56,13 +54,16 @@ from langgraph.graph import END, Graph, StateGraph
from langgraph.graph.message import MessagesState, add_messages
from langgraph.prebuilt.tool_node import ToolNode
from langgraph.pregel import Channel, GraphRecursionError, Pregel, StateSnapshot
from langgraph.pregel.loop import AsyncPregelLoop
from langgraph.pregel.retry import RetryPolicy
from langgraph.pregel.runner import PregelRunner
from langgraph.store.base import BaseStore
from langgraph.types import (
Command,
Interrupt,
PregelTask,
Send,
StateUpdate,
StreamWriter,
interrupt,
)
@@ -77,10 +78,7 @@ from tests.conftest import (
awith_store,
)
from tests.fake_tracer import FakeTracer
from tests.memory_assert import (
MemorySaverAssertCheckpointMetadata,
MemorySaverNoPending,
)
from tests.memory_assert import MemorySaverNoPending
from tests.messages import (
_AnyIdAIMessage,
_AnyIdAIMessageChunk,
@@ -938,10 +936,7 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None:
async for c in tool_two.astream(
{"my_key": "value ⛰️", "market": "DE"}, thread2
)
] == [
{
"tool_one": {"my_key": " one"},
},
] == UnsortedSequence(
{
"__interrupt__": (
Interrupt(
@@ -951,7 +946,10 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None:
),
)
},
]
{
"tool_one": {"my_key": " one"},
},
)
# resume with answer
assert [
c async for c in tool_two.astream(Command(resume=" my answer"), thread2)
@@ -1672,7 +1670,7 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None:
"id": AnyStr(),
"name": "one",
"input": 2,
"triggers": ["input"],
"triggers": ("input",),
},
},
{
@@ -1683,7 +1681,7 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None:
"id": AnyStr(),
"name": "two",
"input": [12],
"triggers": ["inbox"],
"triggers": ("inbox",),
},
},
{
@@ -1718,7 +1716,7 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None:
"id": AnyStr(),
"name": "two",
"input": [3],
"triggers": ["inbox"],
"triggers": ("inbox",),
},
},
{
@@ -4511,6 +4509,136 @@ async def test_in_one_fan_out_state_graph_waiting_edge_via_branch(
]
@pytest.mark.parametrize("version", ["v1", "v2"])
async def test_nested_pydantic_models(version: str) -> None:
"""Test that nested Pydantic models are properly constructed from leaf nodes up."""
# Define nested Pydantic models
if version == "v1":
from pydantic.v1 import BaseModel, Field
else:
from pydantic import BaseModel, Field
class NestedModel(BaseModel):
value: int
name: str
something: Optional[str] = None
# Forward reference model
class RecursiveModel(BaseModel):
value: str
child: Optional["RecursiveModel"] = None
# Discriminated union models
class Cat(BaseModel):
pet_type: Literal["cat"]
meow: str
class Dog(BaseModel):
pet_type: Literal["dog"]
bark: str
# Cyclic reference model
class Person(BaseModel):
id: str
name: str
friends: list[str] = Field(default_factory=list) # IDs of friends
class MyEnum(enum.Enum):
A = 1
B = 2
class MyTypedDict(TypedDict):
x: int
my_enum: MyEnum
class State(BaseModel):
# Basic nested model tests
top_level: str
nested: NestedModel
optional_nested: Optional[NestedModel] = None
dict_nested: dict[str, NestedModel]
my_set: set[int]
my_enum: MyEnum
list_nested: Annotated[
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
]
list_nested_reversed: Annotated[
Union[list[dict[str, NestedModel]], NestedModel, dict, list],
lambda x, y: (x or []) + [y],
]
tuple_nested: tuple[str, NestedModel]
tuple_list_nested: list[tuple[int, NestedModel]]
complex_tuple: tuple[str, dict[str, tuple[int, NestedModel]]]
my_typed_dict: MyTypedDict
# Forward reference test
recursive: RecursiveModel
# Discriminated union test
pet: Union[Cat, Dog]
# Cyclic reference test
people: dict[str, Person] # Map of ID -> Person
inputs = {
# Basic nested models
"top_level": "initial",
"nested": {"value": 42, "name": "test"},
"optional_nested": {"value": 10, "name": "optional"},
"my_set": [1, 2, 7],
"my_enum": MyEnum.B,
"my_typed_dict": {"x": 1, "my_enum": MyEnum.A},
"dict_nested": {"a": {"value": 5, "name": "a"}},
"list_nested": [{"a": {"value": 6, "name": "b"}}],
"list_nested_reversed": ["foo", "bar"],
"tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}],
"tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]],
"complex_tuple": [
"complex",
{"nested": [9, {"value": 10, "name": "deep"}]},
],
# Forward reference
"recursive": {"value": "parent", "child": {"value": "child", "child": None}},
# Discriminated union (using a cat in this case)
"pet": {"pet_type": "cat", "meow": "meow!"},
# Cyclic references
"people": {
"1": {
"id": "1",
"name": "Alice",
"friends": ["2", "3"], # Alice is friends with Bob and Charlie
},
"2": {
"id": "2",
"name": "Bob",
"friends": ["1"], # Bob is friends with Alice
},
"3": {
"id": "3",
"name": "Charlie",
"friends": ["1", "2"], # Charlie is friends with Alice and Bob
},
},
}
update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}}
async def node_fn(state: State) -> dict:
assert state == State(**inputs)
return update
builder = StateGraph(State)
builder.add_node("process", node_fn)
builder.set_entry_point("process")
builder.set_finish_point("process")
graph = builder.compile()
result = await graph.ainvoke(inputs.copy())
assert result == {**inputs, **update}
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class(
snapshot: SnapshotAssertion, mocker: MockerFixture, checkpointer_name: str
@@ -5639,11 +5767,11 @@ async def test_checkpoint_metadata() -> None:
workflow.add_edge("tools", "agent")
# graph w/o interrupt
checkpointer_1 = MemorySaverAssertCheckpointMetadata()
checkpointer_1 = InMemorySaver()
app = workflow.compile(checkpointer=checkpointer_1)
# graph w/ interrupt
checkpointer_2 = MemorySaverAssertCheckpointMetadata()
checkpointer_2 = InMemorySaver()
app_w_interrupt = workflow.compile(
checkpointer=checkpointer_2, interrupt_before=["tools"]
)
@@ -5772,10 +5900,12 @@ async def test_store_injected_async(checkpointer_name: str, store_name: str) ->
):
assert isinstance(store, BaseStore)
await store.aput(
namespace
if self.i is not None
and config["configurable"]["thread_id"] in (thread_1, thread_2)
else (f"foo_{self.i}", "bar"),
(
namespace
if self.i is not None
and config["configurable"]["thread_id"] in (thread_1, thread_2)
else (f"foo_{self.i}", "bar")
),
doc_id,
{
**doc,
@@ -6544,39 +6674,6 @@ async def test_command_goto_with_static_breakpoints(checkpointer_name: str) -> N
assert result == {"foo": "abc|node-1|node-2|node-2"}
async def test_nested_graph_state_error_handling():
"""Test error handling when updating state in nested graphs."""
class State(TypedDict):
count: int
def child_node(state: State):
return {"count": state["count"] + 1}
child = StateGraph(State)
child.add_node("child", child_node)
child.add_edge(START, "child")
parent = StateGraph(State)
parent.add_node("child_graph", child.compile())
parent.add_edge(START, "child_graph")
app = parent.compile(checkpointer=MemorySaver())
# Test invalid state update on parent
with pytest.raises(InvalidUpdateError):
await app.aupdate_state(
{"configurable": {"thread_id": "1"}}, {"invalid_key": "value"}
)
# Test invalid state update on child
with pytest.raises(InvalidUpdateError):
await app.aupdate_state(
{"configurable": {"thread_id": "1", "checkpoint_ns": "child_graph"}},
{"invalid_key": "value"},
)
async def test_parallel_node_execution():
"""Test that parallel nodes execute concurrently."""
@@ -6920,6 +7017,8 @@ async def test_double_interrupt_subgraph(checkpointer_name: str) -> None:
def invoke_sub_agent(state: AgentState):
return subgraph.invoke(state)
thread = {"configurable": {"thread_id": str(uuid.uuid4())}}
parent_agent = (
StateGraph(AgentState)
.add_node("invoke_sub_agent", invoke_sub_agent)
@@ -7494,7 +7593,10 @@ async def test_tags_stream_mode_messages() -> None:
{
"langgraph_step": 1,
"langgraph_node": "call_model",
"langgraph_triggers": ["start:call_model"],
"langgraph_triggers": (
"branch:to:call_model",
"start:call_model",
),
"langgraph_path": ("__pregel_pull", "call_model"),
"langgraph_checkpoint_ns": AnyStr("call_model:"),
"checkpoint_ns": AnyStr("call_model:"),
@@ -7750,3 +7852,329 @@ async def test_handles_multiple_interrupts_from_tasks() -> None:
assert len(result) == 2
assert result[0] == "Added James!"
assert result[1] == "Added Will!"
async def test_pregel_loop_refcount():
gc.collect()
try:
gc.disable()
class State(TypedDict):
messages: Annotated[list, add_messages]
graph_builder = StateGraph(State)
async def chatbot(state: State):
return {"messages": [("ai", "HIYA")]}
graph_builder.add_node("chatbot", chatbot)
graph_builder.set_entry_point("chatbot")
graph_builder.set_finish_point("chatbot")
graph = graph_builder.compile()
for _ in range(5):
await graph.ainvoke({"messages": [{"role": "user", "content": "hi"}]})
assert (
len(
[
obj
for obj in gc.get_objects()
if isinstance(obj, AsyncPregelLoop)
]
)
== 0
)
assert (
len([obj for obj in gc.get_objects() if isinstance(obj, PregelRunner)])
== 0
)
finally:
gc.enable()
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_bulk_state_updates(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
class State(TypedDict):
foo: str
baz: str
def node_a(state: State) -> State:
return {"foo": "bar"}
def node_b(state: State) -> State:
return {"baz": "qux"}
graph = (
StateGraph(State)
.add_node("node_a", node_a)
.add_node("node_b", node_b)
.add_edge(START, "node_a")
.add_edge("node_a", "node_b")
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First update with node_a
await graph.abulk_update_state(
config,
[
[
StateUpdate({"foo": "bar"}, "node_a"),
]
],
)
# Then bulk update with both nodes
await graph.abulk_update_state(
config,
[
[
StateUpdate({"foo": "updated"}, "node_a"),
StateUpdate({"baz": "new"}, "node_b"),
]
],
)
state = await graph.aget_state(config)
assert state.values == {"foo": "updated", "baz": "new"}
# Check if there are only two checkpoints
checkpoints = [
c async for c in checkpointer.alist({"configurable": {"thread_id": "1"}})
]
assert len(checkpoints) == 2
assert checkpoints[0].metadata["writes"] == {
"node_a": {"foo": "updated"},
"node_b": {"baz": "new"},
}
assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}}
# perform multiple steps at the same time
config = {"configurable": {"thread_id": "2"}}
await graph.abulk_update_state(
config,
[
[
StateUpdate({"foo": "bar"}, "node_a"),
],
[
StateUpdate({"foo": "updated"}, "node_a"),
StateUpdate({"baz": "new"}, "node_b"),
],
],
)
state = await graph.aget_state(config)
assert state.values == {"foo": "updated", "baz": "new"}
checkpoints = [
c async for c in checkpointer.alist({"configurable": {"thread_id": "1"}})
]
assert len(checkpoints) == 2
assert checkpoints[0].metadata["writes"] == {
"node_a": {"foo": "updated"},
"node_b": {"baz": "new"},
}
assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}}
# Should raise error if updating without as_node
with pytest.raises(InvalidUpdateError):
await graph.abulk_update_state(
config,
[
[
StateUpdate(values={"foo": "error"}, as_node=None),
StateUpdate(values={"bar": "error"}, as_node=None),
]
],
)
# Should raise if no updates are provided
with pytest.raises(ValueError, match="No supersteps provided"):
await graph.abulk_update_state(config, [])
# Should raise if no updates are provided
with pytest.raises(ValueError, match="No updates provided"):
await graph.abulk_update_state(config, [[], []])
# Should raise if __end__ or __copy__ update is applied in bulk
with pytest.raises(InvalidUpdateError):
await graph.abulk_update_state(
config,
[
[
StateUpdate(values=None, as_node="__end__"),
StateUpdate(values=None, as_node="__copy__"),
],
],
)
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_update_as_input(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
class State(TypedDict):
foo: str
def agent(state: State) -> State:
return {"foo": "agent"}
def tool(state: State) -> State:
return {"foo": "tool"}
graph = (
StateGraph(State)
.add_node("agent", agent)
.add_node("tool", tool)
.add_edge(START, "agent")
.add_edge("agent", "tool")
.compile(checkpointer=checkpointer)
)
assert await graph.ainvoke(
{"foo": "input"}, {"configurable": {"thread_id": "1"}}
) == {"foo": "tool"}
assert await graph.ainvoke(
{"foo": "input"}, {"configurable": {"thread_id": "1"}}
) == {"foo": "tool"}
def map_snapshot(i: StateSnapshot) -> dict:
return {
"values": i.values,
"next": i.next,
"step": i.metadata.get("step"),
}
history = [
map_snapshot(s)
async for s in graph.aget_state_history(
{"configurable": {"thread_id": "1"}}
)
]
await graph.abulk_update_state(
{"configurable": {"thread_id": "2"}},
[
# First turn
[StateUpdate({"foo": "input"}, "__input__")],
[StateUpdate({"foo": "input"}, "__start__")],
[StateUpdate({"foo": "agent"}, "agent")],
[StateUpdate({"foo": "tool"}, "tool")],
# Second turn
[StateUpdate({"foo": "input"}, "__input__")],
[StateUpdate({"foo": "input"}, "__start__")],
[StateUpdate({"foo": "agent"}, "agent")],
[StateUpdate({"foo": "tool"}, "tool")],
],
)
state = await graph.aget_state({"configurable": {"thread_id": "2"}})
assert state.values == {"foo": "tool"}
new_history = [
map_snapshot(s)
async for s in graph.aget_state_history(
{"configurable": {"thread_id": "2"}}
)
]
assert new_history == history
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
async def test_batch_update_as_input(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
class State(TypedDict):
foo: str
tasks: Annotated[list[int], operator.add]
def agent(state: State) -> State:
return {"foo": "agent"}
def map(state: State) -> Command["task"]:
return Command(
goto=[
Send("task", {"index": 0}),
Send("task", {"index": 1}),
Send("task", {"index": 2}),
],
update={"foo": "map"},
)
def task(state: dict) -> State:
return {"tasks": [state["index"]]}
graph = (
StateGraph(State)
.add_node("agent", agent)
.add_node("map", map)
.add_node("task", task)
.add_edge(START, "agent")
.add_edge("agent", "map")
.compile(checkpointer=checkpointer)
)
assert await graph.ainvoke(
{"foo": "input"}, {"configurable": {"thread_id": "1"}}
) == {"foo": "map", "tasks": [0, 1, 2]}
def map_snapshot(i: StateSnapshot) -> dict:
return {
"values": i.values,
"next": i.next,
"step": i.metadata.get("step"),
"tasks": [t.name for t in i.tasks],
}
history = [
map_snapshot(s)
async for s in graph.aget_state_history(
{"configurable": {"thread_id": "1"}}
)
]
await graph.abulk_update_state(
{"configurable": {"thread_id": "2"}},
[
[StateUpdate({"foo": "input"}, "__input__")],
[StateUpdate({"foo": "input"}, "__start__")],
[StateUpdate({"foo": "agent", "tasks": []}, "agent")],
[
StateUpdate(
Command(
goto=[
Send("task", {"index": 0}),
Send("task", {"index": 1}),
Send("task", {"index": 2}),
],
update={"foo": "map"},
),
"map",
)
],
[
StateUpdate({"tasks": [0]}, "task"),
StateUpdate({"tasks": [1]}, "task"),
StateUpdate({"tasks": [2]}, "task"),
],
],
)
state = await graph.aget_state({"configurable": {"thread_id": "2"}})
assert state.values == {"foo": "map", "tasks": [0, 1, 2]}
new_history = [
map_snapshot(s)
async for s in graph.aget_state_history(
{"configurable": {"thread_id": "2"}}
)
]
assert new_history == history
@@ -257,7 +257,7 @@ def _validate_chat_history(
@_convert_modifier_to_prompt
def create_react_agent(
model: Union[str, LanguageModelLike],
tools: Union[Sequence[BaseTool], ToolNode],
tools: Union[Sequence[Union[BaseTool, Callable]], ToolNode],
*,
prompt: Optional[Prompt] = None,
response_format: Optional[
@@ -382,12 +382,11 @@ def create_react_agent(
Use with a simple tool:
```pycon
>>> from datetime import datetime
>>> from langchain_openai import ChatOpenAI
>>> from langgraph.prebuilt import create_react_agent
... def check_weather(location: str, at_time: datetime | None = None) -> str:
... def check_weather(location: str) -> str:
... '''Return the weather forecast for the specified location.'''
... return f"It's always sunny in {location}"
>>>
@@ -595,7 +594,7 @@ def create_react_agent(
```pycon
>>> import time
... def check_weather(location: str, at_time: datetime | None = None) -> float:
... def check_weather(location: str) -> str:
... '''Return the weather forecast for the specified location.'''
... time.sleep(2)
... return f"It's always sunny in {location}"
@@ -720,9 +719,7 @@ def create_react_agent(
def generate_structured_response(
state: StateSchema, config: RunnableConfig
) -> StateSchema:
# NOTE: we exclude the last message because there is enough information
# for the LLM to generate the structured response
messages = _get_state_value(state, "messages")[:-1]
messages = _get_state_value(state, "messages")
structured_response_schema = response_format
if isinstance(response_format, tuple):
system_prompt, structured_response_schema = response_format
@@ -737,9 +734,7 @@ def create_react_agent(
async def agenerate_structured_response(
state: StateSchema, config: RunnableConfig
) -> StateSchema:
# NOTE: we exclude the last message because there is enough information
# for the LLM to generate the structured response
messages = _get_state_value(state, "messages")[:-1]
messages = _get_state_value(state, "messages")
structured_response_schema = response_format
if isinstance(response_format, tuple):
system_prompt, structured_response_schema = response_format
@@ -859,4 +854,7 @@ __all__ = [
"create_react_agent",
"create_tool_calling_executor",
"AgentState",
"AgentStatePydantic",
"AgentStateWithStructuredResponse",
"AgentStateWithStructuredResponsePydantic",
]

Some files were not shown because too many files have changed in this diff Show More