Compare commits

..
Author SHA1 Message Date
William FHandGitHub d7a0da8f06 checkpointer: Enter stack for blobs dict in InMemorySaver (#4419) 2025-04-26 13:57:36 -07:00
Vadym BardaandGitHub 2d9347ec33 docs(cloud): update human-in-the-loop guides to use interrupt() (#4418) 2025-04-26 13:37:06 -04:00
Nuno CamposandGitHub 8951d162f0 Improve logic for drawing virtual end node (#4409)
- Don't draw unexpected edges to END node
- If a conditional edge has a custom label for END node then draw it
2025-04-25 15:45:12 -07:00
Nuno Campos d90126b34e Fix 2025-04-25 15:38:41 -07:00
Nuno CamposandGitHub adc4569daf build(deps): bump h11 from 0.14.0 to 0.16.0 in /libs/checkpoint-sqlite (#4403)
Bumps [h11](https://github.com/python-hyper/h11) from 0.14.0 to 0.16.0.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/python-hyper/h11/commit/1c5b07581f058886c8bdd87adababd7d959dc7ca"><code>1c5b075</code></a>
this time for surer</li>
<li><a
href="https://github.com/python-hyper/h11/commit/d9c369935e853a7ee1aeb7e481f6dddf9b9c9b8a"><code>d9c3699</code></a>
this time for sure...</li>
<li><a
href="https://github.com/python-hyper/h11/commit/d91b9dd2290a25c8c3f5ec15feb57de5873e6e39"><code>d91b9dd</code></a>
blacken</li>
<li><a
href="https://github.com/python-hyper/h11/commit/5a4683ca466b59bbab9b19cfea20ee157b31cee0"><code>5a4683c</code></a>
Soothe mypy</li>
<li><a
href="https://github.com/python-hyper/h11/commit/9c9567f0a92d13a83a8d8ebdbc757c8c2d384536"><code>9c9567f</code></a>
Bump version to 0.16.0</li>
<li><a
href="https://github.com/python-hyper/h11/commit/114803a29ce50116dc47951c690ad4892b1a36ed"><code>114803a</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/python-hyper/h11/commit/9462006f6ce4941661888228cbd4ac1ea80689b0"><code>9462006</code></a>
Bump version to 0.15.0</li>
<li><a
href="https://github.com/python-hyper/h11/commit/70a96bea8e55403e5d92db14c111432c6d7a8685"><code>70a96be</code></a>
Merge pull request <a
href="https://redirect.github.com/python-hyper/h11/issues/181">#181</a>
from Julien00859/Julien00859/get_int_max_str_digits</li>
<li><a
href="https://github.com/python-hyper/h11/commit/60782ad107e538b9312aac7e1c119c8358bf797c"><code>60782ad</code></a>
Reject Content-Length longer 1 billion TB</li>
<li><a
href="https://github.com/python-hyper/h11/commit/dff7cc397a26ed4acdedd92d1bda6c8f18a6ed9f"><code>dff7cc3</code></a>
Validate Chunked-Encoding chunk footer</li>
<li>Additional commits viewable in <a
href="https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=h11&package-manager=pip&previous-version=0.14.0&new-version=0.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>
2025-04-25 15:23:46 -07:00
Nuno Campos 6d70559618 Upgrade 2025-04-25 15:17:06 -07:00
Nuno Campos 70afd2dc2c Update all 2025-04-25 15:11:32 -07:00
ccurmeandGitHub 14e7215090 docs: use langchain-tavily in quickstart (#4412)
We want to deprecate `TavilySearchResults` in langchain-community in
favor of `TavilySearch` in langchain-tavily.

Also update quickstart to use `init_chat_model`.
2025-04-25 17:54:37 -04:00
Nuno Campos 460edde3a7 Lint 2025-04-25 14:37:53 -07:00
Nuno Campos 34d591dedf Improve logic for drawing virtual end node
- Don't draw unexpected edges to END node
- If a conditional edge has a custom label for END node then draw it
2025-04-25 14:37:53 -07:00
Daehwi KimandGitHub 746b5f0730 fix(docs): fix typo (#4411) 2025-04-25 17:10:47 -04:00
Vadym BardaandGitHub 64448404de ci: don't try to render mermaid images when testing notebooks (#4414) 2025-04-25 17:09:18 -04:00
Andrew NguonlyandGitHub 9637f3b1be docs: Document API endpoint for creating new project (#4413) 2025-04-25 10:42:20 -07:00
Jacob LeeandGitHub c7fe85585b docs: Adds additional Google Analytics tag to docs (#4402)
CC @danielrlambert3
2025-04-24 13:17:52 -07:00
dependabot[bot]andGitHub c6971c6a58 build(deps): bump h11 from 0.14.0 to 0.16.0 in /libs/checkpoint-sqlite
Bumps [h11](https://github.com/python-hyper/h11) from 0.14.0 to 0.16.0.
- [Commits](https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0)

---
updated-dependencies:
- dependency-name: h11
  dependency-version: 0.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-24 17:46:01 +00:00
Sydney RunkleandGitHub 2327f8619f docs: fix xlinks (api and general) (#4391)
Mostly focusing on the API docs xrefs now working, for example:

<img width="728" alt="Screenshot 2025-04-23 at 6 43 40 PM"
src="https://github.com/user-attachments/assets/e0939edb-2088-4b4e-92b0-8009aac6a86e"
/>

--> 

<img width="737" alt="Screenshot 2025-04-23 at 7 57 28 PM"
src="https://github.com/user-attachments/assets/d05e28b5-cf4f-41e0-96f8-9910d19f4657"
/>

Also fixed some other broken links / references.

There are lots of broken links related to langgraph plans / deployment
options, I think our new docs writer was going to work on those.
2025-04-24 08:47:08 -07:00
Sydney RunkleandGitHub 4eb124e83d [breaking]: Improve interrupt behavior when stream_mode='values' (#4374)
This PR does a few things:
1. Surfaces interrupts when `stream_mode='values'` (particularly
relevant for `invoke`, where this is the default behavior)
2. Adds an `interrupt_id` property to the `Interrupt` dataclass so that
interrupts can effectively be mapped to resumes
3. Minor docs updates to reflect the new pattern (no need for a special
section on interrupts with `invoke` and `ainvoke`)

* In a different PR (the one with the multiple resume values), as it's
more relevant there: add an `interrupts` property to `StateSnapshot` so
that `interrupts` can easily be iterated over if users are attempting to
map interrupts to resumes.

I **don't** recommend we release this until we have multi-resumes
working.

## Example

We have the following setup where we're sending multiple prompts to the
child graph, which uses `interrupt`:

```py
def child_graph(state):
    human_input = interrupt(state["prompt"])

    return {
        "human_inputs": [human_input],
    }
```

<img width="142" alt="Screenshot 2025-04-23 at 10 01 12 AM"
src="https://github.com/user-attachments/assets/c6238bf1-54ad-4e48-ab0b-60a0bfc18485"
/>

Old behavior:

```py
initial_input = {"prompts": ["a", "b"]}

print(parent_graph.invoke(input=initial_input,config=thread_config,stream_mode="values"))
#> {'prompts': ['a', 'b'], 'human_inputs': []}

print(parent_graph.invoke(Command(resume="hello 1"),config=thread_config,stream_mode="values"))
#> {'prompts': ['a', 'b'], 'human_inputs': ['hello 1']}

print(parent_graph.invoke(Command(resume="hello 2"),config=thread_config,stream_mode="values"))
#> {'prompts': ['a', 'b'], 'human_inputs': ['hello 1', 'hello 2']}
```

New behavior:

```py
initial_input = {"prompts": ["a", "b"]}

print(parent_graph.invoke(input=initial_input,config=thread_config,stream_mode="values"))
"""
{
  "prompts": ["a", "b"],
  "human_inputs": [],
  "__interrupt__": [
    Interrupt(
      value="a",
      resumable=True,
      ns=["child_graph:38d43a18-a5e7-8ab2-ca83-9d80f6e9ca83"]
    ),
    Interrupt(
      value="b",
      resumable=True,
      ns=["child_graph:dad810e8-738e-9f90-41cd-30c0091eb79b"]
    )
  ]
}
"""

print(parent_graph.invoke(Command(resume="hello 1"),config=thread_config,stream_mode="values"))
"""
{
  "prompts": ["a", "b"],
  "human_inputs": ["hello 1"],
  "__interrupt__": [
    Interrupt(
      value="b",
      resumable=True,
      ns=["child_graph:dad810e8-738e-9f90-41cd-30c0091eb79b"]
    )
  ]
}
"""

print(parent_graph.invoke(Command(resume="hello 2"),config=thread_config,stream_mode="values"))
#> {'prompts': ['a', 'b'], 'human_inputs': ['hello 1', 'hello 2']}
```
2025-04-24 08:21:28 -07:00
Sydney Runkle 84e9ebda91 add back auto gen content 2025-04-24 08:16:03 -07:00
Sydney Runkle 48e8576fcf remove auto gen file 2025-04-24 08:15:06 -07:00
William FHandGitHub 6263c2f710 Copy with cached property (#4399) 2025-04-24 07:16:37 -07:00
David DuongandGitHub 17bc55028e docs(js): update semver constraints (#4401) 2025-04-24 15:48:17 +02:00
Tat Dat Duong cd8443f5ce docs(js): update semver constraints 2025-04-24 15:47:38 +02:00
David DuongandGitHub 7e3f31f97c docs(js): update required semver ranges (#4396) 2025-04-24 12:41:12 +02:00
Tat Dat Duong 3db9bf325c docs(js): update required semver ranges 2025-04-24 12:37:26 +02:00
Sydney Runkle 97337355a2 formatting 2025-04-23 21:21:02 -07:00
Sydney Runkle d6c12133ad fixing links 2025-04-23 21:15:55 -07:00
Sydney Runkle d67abdd112 fix docs build 2025-04-23 20:07:16 -07:00
Sydney Runkle 8dec77e129 remove bounds on mkdocs deps 2025-04-23 18:42:42 -07:00
Sydney Runkle b81c21f311 docs updates 2025-04-23 17:48:47 -07:00
Sydney Runkle cd6b086374 await 2025-04-23 16:36:37 -07:00
Sydney Runkle 5929b0b08d more test fixes 2025-04-23 16:19:46 -07:00
Sydney Runkle 9f33b4e529 fix test to reflect bug fix 2025-04-23 16:03:08 -07:00
Sydney RunkleandGitHub f0fb8d9187 Merge branch 'main' into sr/better-interrupts 2025-04-23 15:58:57 -07:00
Sydney RunkleandGitHub 0ff913c50b Fix double interrupt return caused by dynamic tasks (#4389)
Fix issue found in
https://github.com/langchain-ai/langgraph/pull/4374#discussion_r2056701206
2025-04-23 15:56:47 -07:00
Sydney RunkleandGitHub 67075bfb7f Update libs/langgraph/tests/test_pregel_async.py 2025-04-23 15:50:22 -07:00
Sydney Runkle 37b2956758 helpful comments + test 2025-04-23 14:24:57 -07:00
Sydney Runkle f42fc971e3 fix double interrupt raise with task path bool flag 2025-04-23 14:16:35 -07:00
Vadym BardaandGitHub 6bcab08f55 langgraph: release 0.3.33 (#4388) 2025-04-23 16:28:12 -04:00
Sydney Runkle 8fe7c8101f more explicit tests 2025-04-23 11:49:26 -07:00
Vadym BardaandGitHub 6bca615e6a langgraph: allow nested lists/dicts of primitives in RemoteGraph config (#4387) 2025-04-23 14:04:54 -04:00
Vadym BardaandGitHub 0147790937 langgraph: release 0.3.32 (#4386) 2025-04-23 11:51:29 -04:00
Vadym BardaandGitHub 364c572f0f langgraph: fix messages streaming for list of Commands (#4379)
Fixes #4372
2025-04-23 11:46:57 -04:00
langchain-infraandGitHub 8af4b63c69 docs: Add docs for REDIS_KEY_PREFIX, REDIS_CLUSTER env vars (#4334)
# Description 

Add documentation for using the REDIS_KEY_PREFIX, REDIS_CLUSTER env
vars.
2025-04-23 11:17:03 -04:00
Asamu DavidandGitHub 941b4b1287 Merge branch 'main' into da/4-17/add-redis-prefix-support 2025-04-23 15:52:30 +01:00
Asamu DavidandGitHub 24f04fba37 Update REDIS_CLUSTER env doc.md 2025-04-23 15:52:07 +01:00
William FHandGitHub 05f21a384b Add image arg to up command (#4385)
Using this argument, you can get more customization since you can do
`langgraph build` or directly `docker build` your image and then re-use
the `langgraph up --image my-image` and have it also spin up redis &
postgres for you.

Easier then writing your own compose file
2025-04-23 07:48:01 -07:00
Vadym BardaandGitHub 29e9ee2d7b docs(agents): use list of messages format (#4378) 2025-04-23 01:25:00 +00:00
Sydney Runkle d6b4ee348f goodness, last test fix 2025-04-22 17:32:13 -07:00
Sydney Runkle 9e89b68596 fixing prebuilt tests 2025-04-22 17:24:54 -07:00
Sydney Runkle 1251eeaa48 final test fixes, hopefully 2025-04-22 17:21:56 -07:00
Sydney Runkle 85e73653f9 linting and test fixes 2025-04-22 17:10:54 -07:00
Sydney RunkleandGitHub a8b70cb23c Merge branch 'main' into sr/better-interrupts 2025-04-22 13:48:03 -07:00
Sydney RunkleandGitHub 173627a94c langgraph[lint]: Upgrade to Python 3.9+ syntax (#4368)
Also added "UP" (pyupgrade) rule to `pyproject.toml`
2025-04-22 13:47:18 -07:00
Sydney Runkle edd7d608cd final linting 2025-04-22 13:40:53 -07:00
Sydney Runkle 8977a35060 linting post merge 2025-04-22 13:01:40 -07:00
Sydney RunkleandGitHub 0a6e5a18bb Merge branch 'main' into pyupgrade-39 2025-04-22 12:30:53 -07:00
Sydney Runkle abc6323c6c Merge branch 'sr/better-interrupts' of https://github.com/langchain-ai/langgraph into sr/better-interrupts 2025-04-22 11:39:24 -07:00
Sydney Runkle d5a1bb05f5 revert changes to lockfile 2025-04-22 11:35:56 -07:00
Sydney Runkle 8506c6655b more linting 2025-04-22 11:34:38 -07:00
Sydney RunkleandGitHub 44af4d8e3e Merge branch 'main' into sr/better-interrupts 2025-04-22 11:31:46 -07:00
Sydney Runkle 8e6c0be48a try linting + maybe test fix 2025-04-22 11:28:09 -07:00
Sydney Runkle 35523f4081 linting 2025-04-22 10:34:24 -07:00
Sydney Runkle 9a7b1fa12a initial pass - surfacing interrupts for stream_mode='values' 2025-04-22 10:15:19 -07:00
Nuno CamposandGitHub 71bf2f9e85 Rewrite graph drawing logic (#4354)
- It now executes the same pregel algo as when the graph is executed
(without running any user code in nodes or conditional edges) to
discover all the edges
- This means we now support drawing the graph for all Pregel instances,
not just StateGraph
- This is done in preparation for new edge/node type coming in separate
PR
- Known changes
  - custom labels on conditional edges to END are no longer displayed
2025-04-22 09:31:13 -07:00
Nuno Campos b03c647677 Lint 2025-04-22 08:45:50 -07:00
Nuno Campos 5d49188d3e Lint 2025-04-22 08:41:51 -07:00
Nuno Campos deeb2d6e92 Fix 2025-04-22 08:36:42 -07:00
Nuno Campos 0db67d4196 Fix 2025-04-22 08:36:42 -07:00
Nuno Campos 56c9c210c3 Fix 2025-04-22 08:36:42 -07:00
Nuno Campos 3398715258 Rewrite graph drawing logic
- It now executes the same pregel algo as when the graph is executed (without running any user code in nodes or conditional edges) to discover all the edges
- This means we now support drawing the graph for all Pregel instances, not just StateGraph
2025-04-22 08:36:41 -07:00
William Fu-Hinthorn 38d806733d Update site_description
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 08:18:10 -07:00
William FHandGitHub 86ddd8da10 Add docs on tunneling (#4371)
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 15:04:04 +00:00
William FHandGitHub a5f5d0c4df Expose --tunnel flag to dev command (#4370)
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 14:23:09 +00:00
Sydney Runkle cba7d21732 fix tests? 2025-04-21 21:14:14 -07:00
Sydney Runkle b6ea73ff24 linting for 3.12 2025-04-21 21:01:27 -07:00
Sydney Runkle 1a477e57ff upgrade to py39 standards 2025-04-21 20:43:40 -07:00
David Asamu f94eeabebd add doc for REDIS_CLUSTER env var 2025-04-17 23:20:53 +01:00
Asamu DavidandGitHub 9e22a75423 Merge branch 'main' into da/4-17/add-redis-prefix-support 2025-04-17 22:30:10 +01:00
David Asamu e0da491fe6 docs: Add docs for REDIS_KEY_PREFIX env var 2025-04-17 21:38:41 +01:00
142 changed files with 5482 additions and 9869 deletions
+7 -6
View File
@@ -88,10 +88,11 @@ def has_blocklisted_command(code: str, metadata: dict) -> bool:
return True
return False
def add_mermaid_retries(code: str) -> str:
def remove_mermaid(code: str) -> str:
return code.replace(
"draw_mermaid_png()",
"draw_mermaid_png(max_retries=10, retry_delay=2.0)"
"display(Image(graph.get_graph().draw_mermaid_png()))",
# replace with a dummy statement
"print()"
)
@@ -188,12 +189,12 @@ def add_vcr_to_notebook(
return notebook
def add_mermaid_retries_to_notebook(notebook: nbformat.NotebookNode) -> nbformat.NotebookNode:
def remove_mermaid_from_notebook(notebook: nbformat.NotebookNode) -> nbformat.NotebookNode:
for cell in notebook.cells:
if cell.cell_type != "code":
continue
cell.source = add_mermaid_retries(cell.source)
cell.source = remove_mermaid(cell.source)
return notebook
@@ -218,7 +219,7 @@ def process_notebooks(should_comment_install_cells: bool) -> None:
notebook, cassette_prefix=cassette_prefix
)
notebook = add_mermaid_retries_to_notebook(notebook)
notebook = remove_mermaid_from_notebook(notebook)
if notebook_path in NOTEBOOKS_NO_EXECUTION:
# Add a cell at the beginning to indicate that this notebook should not be executed
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
eNqFVMtOHEcUjaWs/BVXvcFY3fMKzMBsEmwT4mDACkSJZUejmu473eWpripXVQ+0EYuQx35+IRCIkPNYeedNVlnkC/w1udVDwyjGsjSL6fu+55y6x+cTNJYreesVlw4Nix192OnxucEXBVr341mOLlPJ6eOd3b2TwvA3mXPa9ptNpnnDsQkXZSNWedMiM3F2OlRJ+fbW7cOA3IMxlkEfAjcRZeT2Hiz31na/Gq/jveLFzpOVg2R5c9Phwe5Sa2u83QtCCKijqTK+yZhbsMBgQaoEF4BLeMRkumGYzj71kTk7GBi0hXCW4jtkmfUfJKhd5kuwZMJkjImP5jIWRYKDROWMS5/x9Dsy48GN5jqaSbuPhqwjJizOOQzbH8SK4JLuBi/PWYq2dhydZ8gSwvjni68tmmgtpazpa10SqDK6BNk2O41P6PfnWhzT/NG6jFXCZTr9PX3JdQgJjgRzeDZzT0/uNu9e3FdSYsXW9GKMqCMm+AT/uD+bK3qEMnXZ9KTT7b6qbXulxulfTGvBY+Yzm8+tkr8Rjpo4xx/OrGOusMen1B3//ec8R2tpl192NuslfnpP2w+0ONtFQzqb/ipTLg9OH9Au0zefGx5CuwdfMgmdVmcZ2t1+u9Nf7sLG1t7/9zhttzpLlbi+pykNYfP2448Oa8F8SC/BSAmh9geFHlR4e4kHfVkIEQY1y7OvmjwSQnClr6eHgeNOIDW6qgobQhE6poQINrj7ohjC4yo1DAojKLJ+JoIy4oz0FTHeSLnLimGDq8qc+kJNUpJn1Tb9hAInKJpU5EpfwTZtZP9+DQ/ndgrB70kLGwRXasJaiBJmooJRIWevGO7YUsagDDD/ZxH2M/QJGcKIG+tAK8t9JBNUKS1yagjcVgFeCqRqJhO4o/QsSJSL4cyJNF7yvnQGz/z4I54+o8fnF6HtiTGo68DMWxg2FAiaGUY3htRF8xZxRsNSCZcZktyAJ4sN2OU5F8yAU7CNbl+Z8bchlKoAliR+HouXcFAAgwpVKKzv6IeloIF3w+yQ9QlcGytDbLYaq51eq9XqdYns+TfttXAUXrP+UDqjkqJC1Te54qEPa3APSdQSDelvo+DUJ4ItTHiRvyOFvDJX1/KzWAuVNvlc4cip6EoVEYuGl3VtlPqyUXtptDrEVqfXXmq/K5A+bF8rgrYeFlz4EwJDoeKxBTXyiJnr0RuwzgjsChmDmsRO1TzytXwq3RBVuS5c9ZRJE6gb8ISAp4PE5RzqGs1ImRysxpiPeAyO2bENoeZTGxXTMfHzcEn1QsjZmL7muVhZXV5Z7a7eRMXsMVZHauB4TuHtRrd79B/qa3Sq
@@ -1 +1 @@
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
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
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
@@ -1 +1 @@
eNqtWAtwVNUZTsU+poOCgi86Uw5bYUH2bnaTkJeAhCUEEkNCNpgENqRn957de83dey/n3rubJWYKVG0p9rEiiIowQkhoihFGbaxCfWFHfFRs8YHWV5V2tLbUVme0A9L/nHs32U0CYVqZzLJ7zzn/+/v+/9wNvQlCDVlTv7ZPVk1CccSEH0Z6Qy8layximDf3xIkpaWJ3fV2wcbdF5ePTJdPUjfL8fKzLXqyaEtV0OeKNaPH8hD8/TgwDx4jRHdbE1BsTPup0xXFHm6m1E9VwlSO/r6DIg1yZXfBkVaeLagqBby7LINQFqxENTFFN9iiAVZTSLKRoWjuydJSUiIqux2qsimJdQklsIEoUgg0iXoea2CLbLeEEQaZEEFaNJKEeBJL5b8mKY7UNG4ZsmFiNwENNU1BUoyAlIZOk19XlQUMGZTaauVaBySbpsO0j1MSyqqSmoaVuRUGwm5oonEIGwTQiyWqMS5dV+IxjFluEw5plcmscy5EWzXLJMtghttyIE7KSkYSilspz4wGnRLau2hrP2zV0PTHdBgqTmKyipGxK/Jgt3cscNFM695r7xuMw+AREtYEitkvFcfsZt67NPt9GiWEpptF2o6GpbJes6hYLUKcLqoim2IEhFzN+i9gkTJFLFjNarDafv8ISO+prm1e0pFJiUzPWGlaWVqVucHW15uRmZLGsGmawbdOwglrVGXJZVAnB95ArU8lhRYt5FbAvIkEyvSJJ5LNfMWatEFE0S8wPgZxQRpB9ugnqSzE0u9owUkmSpT+sjJ5YL1qY4r8CTAcqRnH4pASLqNpSUUGJBxUANtDMuoiJ3AVFs1ClKJvlKCjzXEKmFAwVIOUWS4DZ5kFJglQtaRsSB59lHYwQia5oqTiYizSdoxrM1dSYIYskS0TQtERZQwJASwbxTE4Us7pSRUKz9tUr2GRF7B2uHskGMlKqpqbimmXYlaVZ1FkMYhwcaYo3xAtslFRABtQYyc0Hoxasqhr4T5gQY3h2BFuBgE3BiGCFCHFNlU2NChFMSdRSlJQgM3IziRDWFFFJjZLNodQIKJAxAt2EQlZBsc8P/4mlhSJ8klLf8AiUo0VcP8Im4vo9yDEgK+HsWzDOgjN0+qtZHbI2KMdUxpIM9SwFUJKGQkxwHdiAFWeKrcJXhjy0qKKxshw1SiRLViPB8WFKslP9/wVjMBse5KQD2elwTAEgkCEkVKxoXFLXMIaBgxXI6haWoRygFQDJmdgDUIkSytiU2cGA6YHSBngIJnAIBVrPLsyoll3t0JygzkbWOgc8QFIB3w0H9LqCUzEK1SkKitwOlGojigkUSdiKxZgJXCColxWLAkg1fhyIfI0lR9qdcAAwIAgauMHwnsSyqQCZQ75ESFyufdwaL6pgrUBDwDZUBaEUPOSUwpLPSA3pGgjIxg5ieZAI6GRdiO0TtQg4GrTCRoTKYcJoKqKAUTxwmace3ldxJEJ0+5RO5QSOpEAB7E3ZPYnQuO0UAAvIi1EOA3orby5OC2k7L65nPcFoI5RqFHYDHxlkGP2P3ZoXsqEAaWpWm0NOm2L1h7Cuw0MDlqFShyIrwUghQQgNLQ7HAFByVI7YtZIAkOmcgDwo7PRw0gEjE1QEuIuVnM5mV6UJwQQ1rI+b8BByxuo5ex4A8ubELRvlITWk+vkOSqBVM1VsB1A+BoYao8GEXAhMhyc5MPKG1ALvKB2Dk71BCGQMCijMGlgWGHSH7HOrzsPnrUw1ic7BXNSxgA83IKQu0ZIQPRjETAmiku29qBFDdYNGqiVYYzJ5dEaJZk7fk02DKFE7lmx1MFZgBZAMciAUtUyGtpnMjlmDTgMiYbBliScgnG0nQK3AkeC1yiQ4eg2GFbYAeJVjksmjpCJelRkN3DRKdI2agBfua5WcIKrtKAsTnw9NTnkoqVkKCxP8cVxDwtp5iPnwhnKGNwTxkqOpkRFjAANYG3IYiio7bBGNUnK2YsxJZGYWHHt25Np1i4JC8j8MicMF546GmV1DeW1wTL6B+Q6wYw6zM2GZmhKwINsNySwUfH74G2V2LC4tqjaKAyRRE1jeIcv1ZYsTjcFlX9HsWItFhhMnzpytO93MCXc5cg864fYgd8ZetlANmfU7YHB3nYMLz2L76FzYym5R0EUUJiECs6FIhEJhjgDzt0pMgSnzFRf4Muqcm9b5De/Qm4D0+aDGNldk2JOo0MoIH+Hi8loACqsQGM50SuA6YkDde1iLsFhnd64p1DJMjiiuwYtWGGwC4AeTmduaSmwusS9riF877XGV35RAHmWcBATCW/JSVj/IkDJYwhnz+E3DO1hibQb0uTjmlQYw0QGJMr9xDt1JOoe7mi2JN1b72pld+IbJRgpXF6s9dkeWKWFZXOUIbc3aqoVvhDrhWzvPCYlhVjTYV+9sPohSLQ6e8sPn5aKjrnOE3blo6hzbLy4p+9SoLrZ29UoweRBq/LxbgqEjfWDE64EH7PFBIGpEE0FX+v7YWln3QN+JQrshfRFWuhxX6b52QnQBK1BSPfap9H7o14pDCfmsUvc5yBSYLSOX+xjIBT52pQcqMnbk16dMCUjU5y0s8hbs7xAMfn0nhiGwO0W6R+frj2Uv6DjSDnIE501Jusc+3J+9RzPSe2pxpC6YI5IVU3oPpvHiogezn1MLmlWcpHsD9SPVOYtD6gq9fr+35ECOYLhwRdJ7OBkcGAzy4JE+myOLgSP7M1FSAL2mlO4u8pUU7QU86oAw8sMeNpVYxoZuSAl54dle58XMrrqaTDrfzruiexGkJ31oMZU9wGSIERoomIP8xeX+wvLCQlRV27gv4OhpHDUbBxph3jZgHBcqM9nvjUiW2k7EvsCoeT/uGvKLEYoCnGMKzlspyBb7ybzx+Y7POOdOCgiBlggauwvLysrGkAuRIWb6IeYfazL+kkbHS9/K0fVwIAr2Cy7Hqh5mFdh1zZj7h2zLnJlxHmfOYmHhyuPu0U4DiY4wcU8p1zZ77P1DJjpn3Odz5uwmotGODwufrejqc+zMDpy9G51z91nt6XMyL8hi+iB8hz5ctWRJk2w2RGva6xubG2pLKsMr17aX7U7ION3n9/pRTNNiCnkgsFgIYOBfIcghlO5d1LKsonZpYF+z0KCFNailRgw1p8JQ2hMkFLCZ7uNvK4DtKOmB4w0VLemHynyFJbjAV1xcNkcsxhEsLAQSyYBpECzdjCr5O9X1PTZTP3OBOHXTt/L4v3Fi8En1jwsmnt7y8uq8Ixv/sED+5JKJey+7Qlo/rvJlcd7GptaXtt311iP3ffT4buvSu/vcz++4q+TkodRvk/etefhvyqFt9950euNrb0798tRz87s2rz/ZXzz1zt6mfiq/GfzQVRja/Kvt1Z6pRz3NC5f103uLJrf+/abDfzr6wWcfmOGnrpcLdr74zgtv/z7WT1eXXuJuOvPodetmPDr7/fd3J14T/iUo05+untxQc/QvF1918T1za9+bN2UOmrv10N7b6yquXLulVtr7y3cvmFuwf/z+i78z5b3q/LU7L5tU8e4W6UfvT/ZOd3++4eCX+14fyB9/h+u+5V1Pl5w6c+14aWJp9zq/OmHe935a88Ftj96y+cFv/+RV+aqD6g8rglvx+Pbi5obqbt/Nx/7x49iKP184y5w8bhIqWZDsOvzENwKP3bL++2i6b9cT7c0n7tiy+IWnxAv9Cz8TZrb87EiP/taloeiMbSd/8/FM83Dl7DkL9vp/sXD8QNFzzekpV6+f77rz1tNbD80ft/zZ02rs9qp7Fv96WkvZbZ+v2zjln8tpcF3DmuRfN6w70R+46Aebdk565PWyjie+2TIv/OpL2z2T3unMb335naO37vq0ctOTNXf3975yyc4J5IvZC/ZUzdo18cC273ZMGHj8+U9Or/IMTLv1Irn56zeih69JvTW/ZvXDdc3TNseTS29e9unMlmcu33OlftXzmy7qfmbgfvGW6+76eCYN1Iyv+7e15JVj8071b5/+4RXV0lP9icMvuggZyL/y9JHDO0Ivlv3uuROfvLbvZOu783srb18VNj47cupU64m5/9l6+Wr52MGONwquPbZlf2r15u078nbcO3/uqXF5eWfOjMu7G+V/UXJhXt5/AU5O7B0=
eNrNWmuMZEUVXjQaH5AIUaImSKVFZkf79vRjnq2JLrMPB2ZncGZ23McsbfW91d21c7vupere6W3GzeoiamIUGzUG/vAaZmEk4AoRVIwPQrLgI4AaM0RNMERRSEw0Rn+Y4DlV93bf7unZhaiJ82N2+t6qc06d833nUb2nTq8wqbgnLrifi4BJagfwQbVOnZbs+pCp4DPrdRbUPGft2tn5hbtDyTffVwsCXxWHhqjPM1QENen53M7YXn1oJTdUZ0rRKlNrZc9pPvfuU6upOj1eCrxlJlSqmMvmh9OpeE2qeGQ1JT2XpYqpUDGZSqdsD6wQATyYpII0vZC4nrdMQp80akyQaSqq+yT1a6RBFZHMZVQx58Pk4/gSV9foCiNBjREqVIPJNAG5+nMtrFNRokpxFVBhw0PPc0nFkyBlhbNGJnUi3TYmXhYkLQJbA3YcTZsacF2iGJV2TUvgAn7XKXqO0LIXBh07B9pWEocGaJaD5ghjKA8i7czJgKqg6aN2rQWtiT+DpSU4B6wQtK6f0BXuNkvGBHjMhR+CYaspCJlswgLtD3RRx2EdZ3HBA07dLsNAX4o7kbKwlM3NXZ8fr151bA9fnL6msX/MyxcEXZSpE0cTbuqN2ZFukyVTodvlwtTqkjFxKVUkS6/dyqVUGrZVPNf1GqXQL2mEIl5RnghdF1+byCef8DqiDZ8cOYqfjWHmAVgU8MBlxqKOIR8Lub0MGJCBURpK1yyJ0W97Li1nQJQOQqbqeVWXaRZUeVALy0MuyLJrlAuLcv2hioKHyq5XHqrD4yHHs5X5FYSBJ+GwaghIKD0n1CzMcL8pykZ95EJjwoJHVFivU8lvYEWSy3TBDQRVuehxXJFcTUVIZZPkxtIkDywk+cQ2Mul6oQPgFF4I3KiDJtgRCkby8fKpAGQ3PBnUiPACLqoAY5oAOgDaIWUGAeWCOGyFuZ6PcjTikYRIFOXVgXgcfpUZfDbMPJcVaVIGNuEqr1LhNuKhFxdeJSGBB4q5FY0p4GLvmTPGl8oG1ejJbGaiMDxeyE5oUNBGKeFlxM6JNOmGx5zRqciPHyH9w0ssso8HHw3L26DGYEPDZBt8ROdSfeLeXlRsnz6bKWQKWZJ4o0LfhzARFUhG6xioON2SivTqZNID5AgnE/o6Ie0k7x3Oj2TJYFJGDRa4jPhNB3Igt4lZqwhYg4kRDHKbGOdIViRkhAxucXAhlyuMjwyPbOfhmI8+SGYlhAa+ymXG8ycwIUaZr/RqUhMkMFViUnoyVawAl1hXrjpPSt9lg82OhrWn8Ral9yhVpHsqT4xGcEOcsfoCjkyzAPwflyWuumpFnPsJ1tCwWusUKtJTqF5TdegtdckCES3pnCaCNNmNYFhkksPBtHWwq8yB7g7FgoKnsbI5Kze2pVLsOV49fHBkRi6y0eb83HBlUewZlvP/aaXYTx2oloA4KZnOhkWyOoDWDxTJQCffpclAbCW+gAC0nT+wPYK2Mbkfgo5Cu+I5zIXdtktDh1kFa8RSnhAssFxkRRCpMe3MNhXaYcqW3NeOBbTF8GICcjXkMHhRh2Tu6CwJycGXDGqj4issTahthxIUpU3zIEMgoBMDM0MOKFYJTSfTiLsgwRBUXtQEkXaZjLoTkCcxNUOWFihiKsCcDgDWcA4khxeKHJibVlqlEtz3GZIAkzFAHzpGQp0VxJYTn8RhflCDJY6HtY0AAiHlmBSuE76EkEER4C40mcocRZflaH/asEM3ZeBtjl0p2EJdC3f3OsHmgcaoVab2csIZUwhzompe6GIxAvxE1ummIxMToaTsGqtT5IMPzSuTAcdmtN0+rfaEaz4hBN0ataQdTkKuhdShmSFsF0BSMm7QQqlozlY0NnjA6vrRlm3RAyolsC1BbsyRKcSgwyoUKVI8cnQrmFwgOtbBSCmaCB4B4XbQk8fgVWZJLAkS/RxQUVryqQTgQmw0iIqdFdBdLEBWQgITdtx3Ie0FGiYaVN35TNcX5TMbkwhpsLLiWDV2skw1g3l+L8fYuRD3QLcl1OwQVFHon1aWUoMdvfmEXoSRga+A9qZKBb/BKIzIAh4mDSisiG2jvmkaFBbDsduErf06v7bmibhC7vJ9LPiDSUdNCVL2oPexsTSnNckimDnotTqyWI8dPiIKermAtcMRKT+ylOqc9CjaDk8o6sJ2AB4NGraxoCckXRGbiwLZ4DCDzM5MH0IPMGO4rlvG/UCKWL0F5CZgO8P+K5Q2izYDPyK0OpmO/N0GaAQMmAGXkJ2wOXJijClw22BiB+KXHf//gH1kR4TELuj/l3GP2XXF44BnSB/onW3gvhVrMQGg98OUiDkVs6+JIMAYI64BcT424K4GNmiA9Zjf3RRMkkW1KSJMW7gtPXzkQtScqNi+Gm75n9AjEbYOU7oc8d8hi6k9BD0AjRuJWo1zMOfVsULbrvpwwsCvpAtjNyGYCOvwR6pMFbehisS1NHV0a0U5Byfa+7uJMenhDOmqGP8QdN1dQn4z9dxgpbvNwJeR/d0sWTJallJmgON1CIGuhFAyNRs4GgpPYFxO49BBebUWwNoGlU7UgCQ92Ss9Pjso2BmdbLDdBbnseKwrbeJDXd0mBXjlBM860iSVLEm2NIkbIhiwdAzAGOo2FfbgSndIXXkvWbzNfUF3zKIolKFBYVScOzC6d+wNy+6YCKC/0j1iBKEU5p4IfBGYvghC5XrgWCQtwZ4/kce63DiPFPDIggyZOW9wrrS1wlUI1nWcY3Q1NFc7QhWvCj0J6EmPiZqeRELhQDBhptCDUjtpzNe8Bg45DbwQKLvQkpEaeEmZPsnlyywN0U1AIEozUEpCOBSm8DkmKIcpFbXoK5fBvpCZZjhHwfy1Fz3cA5i6B+UggQDqtnEKR8XLBvQixF6FenoD8vfEH1vNkm5U+/MVR6F0qsHYcgqnAhFgZ9+EKL5G4up7qR54TMMAEKhEn9ZOUH5YhkpXA9QjFiDj0E6CR4srmPn6uku3B+0UuS04Ou2VadWhIeZeEg3tSJt5h+yaAv40lI4qFO+A227cO7kU3qOHtgvhXoiUi5nI9/zQpRKRKDj0421O42VRWXow/8nE6KCS9iwZ74N+2L2kQwD6dCURCjCVCHGHVtvwZ1ZHQOH9CsQX01B+uDaIpzXHIDvHHP0xVrmzkDUPjFr4PDri9D9sT9nohRseuD/SqpCQJXVxpgdHwz8VLvQo/xorREdOz0QT1bp2kYCZn1U9GG+QSZ1xdBufmdQdCd+SuTUR22l7amZy+sDuqZl9WIhgX5TQZF3p1NCVFwy+DK7g4NHfOsaS2freyMBYMhhCZXRdE9mR5P6WZm8p9mBUyOpULuuRlguYdQOkAHxgwDmvDrzC4URXELML7xsxEH3E4mOQqamGgn3P5YHGscJrOKWl1OkxrGhdYzfwG4Zc8HG5mfCBbjz0zR3Q0uEUuveZ2QVTd5vQstsU6UGjWZSqZaW1ajvAjG4XdMAGaDO1GpoMgJeZcztY8srHmB3gstVzXCJ1Q2jOlJTkTZXOANTcYJ1/2DaK+kwFnWunLS97zqFFJDb0OdHRE6drDHOJ+t2Oi9cAgUHrzJYvrh6ktg0tgsWE7WFtaz1UvYH7aWheK66+dLhBBc6GjZc+evxpbSwz5lsU7ykeOm4pHAAwqenLCmgwW/dB0Er7phb3zKwb0a1vYvMaXa0NHVOeuD/K7xZavPX1BmZpC4qzCFqP7IqNHbq2CSwSJJsZyWay30yqxuvb1rqv338v+cKHggxyrOiLvta62fxAco2nWvfsp/bsfJdIzAyte6isjw53nVKGAg/aOj157VZ10cuOukIml8uMnekSrJrCbt2j+6Qz7Ui0t2zks/mClR21srlHukSzQDYt2wMNrTuzD8QOdJmoBrXW2ujY+Oi98V3yjeuwLwjVqTUIKfvp2dPRLfhds9d0APHONbz2bH1/r+Rpkh+B0Ubi1eEIyY0UC2PF7DjZt3/h/slIz0LfQJ1ZgPqkIB1Ze2L0nLZroVhmzsZkX8RsXtk5Mt5puVj6LU0Wy3xRaulHrfXhLP5svv+86yXDxIG64z1Xvoo90Bq0HsbzWtlhKz+yEJ/68OZAv90A7C0m3jOutX3g/Os7JkZ7Bl7Nnu1NTPXbHve5kXVrw9u6or2yY9daYWJi4jxyt7Vo7PAm6bezJ6Dm6FecY2UylGY1OefqbT20ERltcaf1GPxdyuZykzOLtcXhg8uOf1UFkkrj+mpl5MB3OvKTFwa4b/OKwsRoYcQplC1WrjjW8MT4mDUxkc9Z5Xx+3Bkez40NO6N3r3Da2shlcsR8C/qgXbFsCmnfMhxsnd59aGbX/qnJ+w9ac17ZAz8uUPC3gJ5ofZ5J4H1rw8Yv/yDdSrY+udea23Wo9fBEYYSx8lg55+TyE9m8bV0FCSpmY5tta5ir9X83+PS6qRNPXHD55V940w7983rn5meOPZ69cM/JPS9uDvLvPvuzD79t5+pvrhvd+7Q78uRtZ29lD9307MnLB1+64+WpO1T4aJH+5Znf31uff2C5ufnz5w/9+Ys//cd3fnXzoydPjt/1o+HZx9YOfu3w3htzF/7k1MLq/ivefNGXfvS3T136249sqjOD377pjRfP7Dmw2HjpWxtP3/HWWw5e8tyvf3Hgtn+uTvzgwi/MXjb59X2X/FNN7ntL5uydN7939aOXTQ597m37rj7x5Xd86JaHw7X7bhyae/GJwurnP5QdXH3T61Zvmb7+s3//+Q+fZTf9cezG2378y91XXnTq0kOf/Ip8+7/e8NTttz6RO/zk45/Ilt7zyMDL7Kn7pp4/e1ftG/Lj7wpeKIqlF665+w+jc9c1nb++cujsn/Y++sHbT4InXnnl9TsuXXpx46sX7Njxb2v/Atc=
@@ -1 +1 @@
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
@@ -0,0 +1 @@
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
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
@@ -1 +1 @@
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
@@ -1 +1 @@
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
+10 -6
View File
@@ -29,7 +29,9 @@ agent = create_react_agent(
)
# Run the agent
agent.invoke({"messages": "what is the weather in sf"})
agent.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]}
)
```
1. Define a tool for the agent to use. Tools can be defined as vanilla Python functions. For more advanced tool usage and customization, check the [tools](./tools.md) page.
@@ -85,7 +87,7 @@ agent = create_react_agent(
)
agent.invoke(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]}
)
```
@@ -113,7 +115,7 @@ agent = create_react_agent(
)
agent.invoke(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
config={"configurable": {"user_name": "John Smith"}}
)
@@ -150,12 +152,12 @@ agent = create_react_agent(
# highlight-next-line
config = {"configurable": {"thread_id": "1"}}
sf_response = agent.invoke(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
config # (2)!
)
ny_response = agent.invoke(
{"messages": "what about new york?"},
{"messages": [{"role": "user", "content": "what about new york?"}]},
# highlight-next-line
config
)
@@ -189,7 +191,9 @@ agent = create_react_agent(
response_format=WeatherResponse # (1)!
)
response = agent.invoke({"messages": "what is the weather in sf"})
response = agent.invoke(
{"messages": [{"role": "user", "content": "what is the weather in sf"}]}
)
# highlight-next-line
response["structured_response"]
+3 -3
View File
@@ -36,7 +36,7 @@ for this purpose:
```python
agent.invoke(
{"messages": "hi!"},
{"messages": [{"role": "user", "content": "hi!"}]},
# highlight-next-line
config={"configurable": {"user_id": "user_123"}}
)
@@ -183,7 +183,7 @@ Tools can access context through special parameter **annotations**.
)
agent.invoke(
{"messages": "look up user information"},
{"messages": [{"role": "user", "content": "look up user information"}]},
# highlight-next-line
config={"configurable": {"user_id": "user_123"}}
)
@@ -278,7 +278,7 @@ agent = create_react_agent(
)
agent.invoke(
{"messages": "greet the user"},
{"messages": [{"role": "user", "content": "greet the user"}]},
# highlight-next-line
config={"configurable": {"user_id": "user_123"}}
)
+2 -2
View File
@@ -70,7 +70,7 @@ config = {
}
for chunk in agent.stream(
{"messages": "book a stay at McKittrick hotel"},
{"messages": [{"role": "user", "content": "book a stay at McKittrick hotel"}]},
# highlight-next-line
config
):
@@ -194,7 +194,7 @@ config = {"configurable": {"thread_id": "1"}}
# Run the agent
for chunk in agent.stream(
{"messages": "book a stay at McKittrick hotel"},
{"messages": [{"role": "user", "content": "book a stay at McKittrick hotel"}]},
# highlight-next-line
config
):
+6 -2
View File
@@ -40,8 +40,12 @@ async with MultiServerMCPClient(
# highlight-next-line
client.get_tools()
)
math_response = await agent.ainvoke({"messages": "what's (3 + 5) x 12?"})
weather_response = await agent.ainvoke({"messages": "what is the weather in nyc?"})
math_response = await agent.ainvoke(
{"messages": [{"role": "user", "content": "what's (3 + 5) x 12?"}]}
)
weather_response = await agent.ainvoke(
{"messages": [{"role": "user", "content": "what is the weather in nyc?"}]}
)
```
## Custom MCP servers
+7 -7
View File
@@ -59,14 +59,14 @@ config = {
}
sf_response = agent.invoke(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
config
)
# Continue the conversation using the same thread_id
ny_response = agent.invoke(
{"messages": "what about new york?"},
{"messages": [{"role": "user", "content": "what about new york?"}]},
# highlight-next-line
config # (4)!
)
@@ -188,13 +188,13 @@ agent = create_react_agent(
# Run the agent
agent.invoke(
{"messages": "look up user information"},
{"messages": [{"role": "user", "content": "look up user information"}]},
# highlight-next-line
config={"configurable": {"user_id": "user_123"}}
)
```
1. The `InMemoryStore` is a store that stores data in memory. In a production setting, you would typically use a database or other persistent storage. Please review the [store documentation](../reference/stores.md) for more options. If you're deploying with **LangGraph Platform**, the platform will provide a production-ready store for you.
1. The `InMemoryStore` is a store that stores data in memory. In a production setting, you would typically use a database or other persistent storage. Please review the [store documentation](../reference/store.md) for more options. If you're deploying with **LangGraph Platform**, the platform will provide a production-ready store for you.
2. For this example, we write some sample data to the store using the `put` method. Please see the [BaseStore.put][langgraph.store.base.BaseStore.put] API reference for more details.
3. The first argument is the namespace. This is used to group related data together. In this case, we are using the `users` namespace to group user data.
4. A key within the namespace. This example uses a user ID for the key.
@@ -206,7 +206,7 @@ agent.invoke(
### Writing
```python title="Example of a tool that updates user information"
from typing import TypedDict
from typing_extensions import TypedDict
from langgraph.config import get_store
from langgraph.prebuilt import create_react_agent
@@ -236,7 +236,7 @@ agent = create_react_agent(
# Run the agent
agent.invoke(
{"messages": "My name is John Smith"},
{"messages": [{"role": "user", "content": "My name is John Smith"}]},
# highlight-next-line
config={"configurable": {"user_id": "user_123"}} # (6)!
)
@@ -245,7 +245,7 @@ agent.invoke(
store.get(("users",), "user_123").value
```
1. The `InMemoryStore` is a store that stores data in memory. In a production setting, you would typically use a database or other persistent storage. Please review the [store documentation](../reference/stores.md) for more options. If you're deploying with **LangGraph Platform**, the platform will provide a production-ready store for you.
1. The `InMemoryStore` is a store that stores data in memory. In a production setting, you would typically use a database or other persistent storage. Please review the [store documentation](../reference/store.md) for more options. If you're deploying with **LangGraph Platform**, the platform will provide a production-ready store for you.
2. The `UserInfo` class is a `TypedDict` that defines the structure of the user information. The LLM will use this to format the response according to the schema.
3. The `save_user_info` function is a tool that allows an agent to update user information. This could be useful for a chat application where the user wants to update their profile information.
4. The `get_store` function is used to access the store. You can call it from anywhere in your code, including tools and prompts. This function returns the store that was passed to the agent when it was created.
+34 -10
View File
@@ -53,12 +53,22 @@ hotel_assistant = create_react_agent(
supervisor = create_supervisor(
agents=[flight_assistant, hotel_assistant],
model=ChatOpenAI(model="gpt-4o"),
prompt="You manage a hotel booking assistant and a flight booking assistant. Assign work to them."
prompt=(
"You manage a hotel booking assistant and a"
"flight booking assistant. Assign work to them."
)
).compile()
for chunk in supervisor.stream({
"messages": "book a flight from BOS to JFK and a stay at McKittrick Hotel"
}):
for chunk in supervisor.stream(
{
"messages": [
{
"role": "user",
"content": "book a flight from BOS to JFK and a stay at McKittrick Hotel"
}
]
}
):
print(chunk)
print("\n")
```
@@ -110,9 +120,16 @@ swarm = create_swarm(
default_active_agent="flight_assistant"
).compile()
for chunk in supervisor.stream({
"messages": "book a flight from BOS to JFK and a stay at McKittrick Hotel"
}):
for chunk in swarm.stream(
{
"messages": [
{
"role": "user",
"content": "book a flight from BOS to JFK and a stay at McKittrick Hotel"
}
]
}
):
print(chunk)
print("\n")
```
@@ -253,9 +270,16 @@ multi_agent_graph = (
)
# Run the multi-agent graph
for chunk in multi_agent_graph.stream({
"messages": "book a flight from BOS to JFK and a stay at McKittrick Hotel"
}):
for chunk in multi_agent_graph.stream(
{
"messages": [
{
"role": "user",
"content": "book a flight from BOS to JFK and a stay at McKittrick Hotel"
}
]
}
):
print(chunk)
print("\n")
```
+7 -7
View File
@@ -1,7 +1,7 @@
# Running agents
Agents support both synchronous and asynchronous execution using either `.invoke()` / `await .invoke()` for full responses, or `.stream()` / `.astream()` for **incremental** [streaming](#streaming) output. This section explains how to provide input, interpret output, enable streaming, and control execution limits.
Agents support both synchronous and asynchronous execution using either `.invoke()` / `await .invoke()` for full responses, or `.stream()` / `.astream()` for **incremental** [streaming](streaming.md) output. This section explains how to provide input, interpret output, enable streaming, and control execution limits.
## Basic usage
@@ -18,7 +18,7 @@ Agents can be executed in two primary modes:
agent = create_react_agent(...)
# highlight-next-line
response = agent.invoke({"messages": "what is the weather in sf"})
response = agent.invoke({"messages": [{"role": "user", "content": "what is the weather in sf"}]})
```
=== "Async invocation"
@@ -27,7 +27,7 @@ Agents can be executed in two primary modes:
agent = create_react_agent(...)
# highlight-next-line
response = await agent.ainvoke({"messages": "what is the weather in sf"})
response = await agent.ainvoke({"messages": [{"role": "user", "content": "what is the weather in sf"}]})
```
## Inputs and outputs
@@ -82,7 +82,7 @@ Streaming is available in both sync and async modes:
```python
for chunk in agent.stream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
stream_mode="updates"
):
print(chunk)
@@ -92,7 +92,7 @@ Streaming is available in both sync and async modes:
```python
async for chunk in agent.astream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
stream_mode="updates"
):
print(chunk)
@@ -122,7 +122,7 @@ To control agent execution and avoid infinite loops, set a recursion limit. This
try:
response = agent.invoke(
{"messages": "what's the weather in sf"},
{"messages": [{"role": "user", "content": "what's the weather in sf"}]},
# highlight-next-line
{"recursion_limit": recursion_limit},
)
@@ -148,7 +148,7 @@ To control agent execution and avoid infinite loops, set a recursion limit. This
try:
response = agent_with_recursion_limit.invoke(
{"messages": "what's the weather in sf"},
{"messages": [{"role": "user", "content": "what's the weather in sf"}]},
)
except GraphRecursionError:
print("Agent stopped due to max iterations.")
+8 -8
View File
@@ -35,7 +35,7 @@ For example, if you have an agent that calls a tool once, you should see the fol
)
# highlight-next-line
for chunk in agent.stream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode="updates"
):
@@ -52,7 +52,7 @@ For example, if you have an agent that calls a tool once, you should see the fol
)
# highlight-next-line
async for chunk in agent.astream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode="updates"
):
@@ -73,7 +73,7 @@ To stream tokens as they are produced by the LLM, use `stream_mode="messages"`:
)
# highlight-next-line
for token, metadata in agent.stream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode="messages"
):
@@ -91,7 +91,7 @@ To stream tokens as they are produced by the LLM, use `stream_mode="messages"`:
)
# highlight-next-line
async for token, metadata in agent.astream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode="messages"
):
@@ -125,7 +125,7 @@ To stream updates from tools as they are executed, you can use [get_stream_write
)
for chunk in agent.stream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode="custom"
):
@@ -154,7 +154,7 @@ To stream updates from tools as they are executed, you can use [get_stream_write
)
async for chunk in agent.astream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode="custom"
):
@@ -178,7 +178,7 @@ You can specify multiple streaming modes by passing stream mode as a list: `stre
)
for stream_mode, chunk in agent.stream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode=["updates", "messages", "custom"]
):
@@ -195,7 +195,7 @@ You can specify multiple streaming modes by passing stream mode as a list: `stre
)
async for stream_mode, chunk in agent.astream(
{"messages": "what is the weather in sf"},
{"messages": [{"role": "user", "content": "what is the weather in sf"}]},
# highlight-next-line
stream_mode=["updates", "messages", "custom"]
):
+18 -6
View File
@@ -116,7 +116,9 @@ agent = create_react_agent(
tools=tools
)
agent.invoke({"messages": "what's 3 + 5 and 4 * 7? make both calculations in parallel"})
agent.invoke(
{"messages": [{"role": "user", "content": "what's 3 + 5 and 4 * 7?"}]}
)
```
## Return tool results directly
@@ -137,7 +139,9 @@ agent = create_react_agent(
tools=[add]
)
agent.invoke({"messages": "what's 3 + 5?"})
agent.invoke(
{"messages": [{"role": "user", "content": "what's 3 + 5?"}]}
)
```
## Force tool use
@@ -161,7 +165,9 @@ agent = create_react_agent(
tools=tools
)
agent.invoke({"messages": "Hi, I am Bob"})
agent.invoke(
{"messages": [{"role": "user", "content": "Hi, I am Bob"}]}
)
```
!!! Warning "Avoid infinite loops"
@@ -191,7 +197,9 @@ By default, the agent will catch all exceptions raised during tool calls and wil
model="anthropic:claude-3-7-sonnet-latest",
tools=[multiply]
)
agent.invoke({"messages": "what's 42 x 7?"})
agent.invoke(
{"messages": [{"role": "user", "content": "what's 42 x 7?"}]}
)
```
=== "Disable error handling"
@@ -215,7 +223,9 @@ By default, the agent will catch all exceptions raised during tool calls and wil
model="anthropic:claude-3-7-sonnet-latest",
tools=tool_node
)
agent_no_error_handling.invoke({"messages": "what's 42 x 7?"})
agent_no_error_handling.invoke(
{"messages": [{"role": "user", "content": "what's 42 x 7?"}]}
)
```
1. This disables error handling (enabled by default). See all available strategies in the [API reference][langgraph.prebuilt.tool_node.ToolNode].
@@ -243,7 +253,9 @@ By default, the agent will catch all exceptions raised during tool calls and wil
model="anthropic:claude-3-7-sonnet-latest",
tools=tool_node
)
agent_custom_error_handling.invoke({"messages": "what's 42 x 7?"})
agent_custom_error_handling.invoke(
{"messages": [{"role": "user", "content": "what's 42 x 7?"}]}
)
```
1. This provides a custom message to send to the LLM in case of an exception. See all available strategies in the [API reference][langgraph.prebuilt.tool_node.ToolNode].
+15 -12
View File
@@ -40,6 +40,14 @@ Example `package.json` file:
}
```
When deploying your app, the dependencies will be installed using the package manager of your choice, provided they adhere to the compatible version ranges listed below:
```
"@langchain/core": "^0.3.42",
"@langchain/langgraph": "^0.2.57",
"@langchain/langgraph-checkpoint": "~0.0.16",
```
Example file directory:
```bash
@@ -82,14 +90,10 @@ import { ChatOpenAI } from "@langchain/openai";
import { MessagesAnnotation, StateGraph } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
const tools = [
new TavilySearchResults({ maxResults: 3, }),
];
const tools = [new TavilySearchResults({ maxResults: 3 })];
// Define the function that calls the model
async function callModel(
state: typeof MessagesAnnotation.State,
) {
async function callModel(state: typeof MessagesAnnotation.State) {
/**
* Call the LLM powering our agent.
* Feel free to customize the prompt, model, and other logic!
@@ -101,9 +105,9 @@ async function callModel(
const response = await model.invoke([
{
role: "system",
content: `You are a helpful assistant. The current date is ${new Date().getTime()}.`
content: `You are a helpful assistant. The current date is ${new Date().getTime()}.`,
},
...state.messages
...state.messages,
]);
// MessagesAnnotation supports returning a single message or array of messages
@@ -141,10 +145,7 @@ const workflow = new StateGraph(MessagesAnnotation)
routeModelOutput,
// List of the possible destinations the conditional edge can route to.
// Required for conditional edges to properly render the graph in Studio
[
"tools",
"__end__"
],
["tools", "__end__"]
)
// This means that after `tools` is called, `callModel` node is called next.
.addEdge("tools", "callModel");
@@ -155,6 +156,7 @@ export const graph = workflow.compile();
```
!!! info "Assign `CompiledGraph` to Variable"
The build process for LangGraph Cloud requires that the `CompiledGraph` object be assigned to a variable at the top-level of a JavaScript module (alternatively, you can provide [a function that creates a graph](./graph_rebuild.md)).
Example file directory:
@@ -193,6 +195,7 @@ Example `langgraph.json` file:
Note that the variable name of the `CompiledGraph` appears at the end of the value of each subkey in the top-level `graphs` key (i.e. `:<variable_name>`).
!!! info "Configuration Location"
The LangGraph API configuration file must be placed in a directory that is at the same level or higher than the TypeScript files that contain compiled graphs and associated dependencies.
## Next
@@ -22,7 +22,7 @@ To support this, LangGraph Studio, in combination with LangSmith, allows you to
First navigate to the LangSmith trace. Here you should see a button to "Run in Studio".
![Run in Studio](../img/run_in_studio.png){width=1200}
![Run in Studio](img/run_in_studio.png){width=1200}
This will prompt you to enter the url that your locally running agent is accessible at. Once provided, select "Clone thread locally". If you have multiple graphs in your agent, you will also be prompted to select a graph to clone this thread under.
@@ -1,12 +1,16 @@
# How to Add Breakpoints
# How to add static breakpoints
When creating LangGraph agents, it is often nice to add a human-in-the-loop component.
This can be helpful when giving them access to tools.
Often in these situations you may want to manually approve an action before taking.
!!! tip "Prerequisites"
This can be in several ways, but the primary supported way is to add an "interrupt" before a node is executed.
This interrupts execution at that node.
You can then resume from that spot to continue.
This guide assumes familiarity with the following concepts:
* [Breakpoints](../../concepts/breakpoints.md)
* [LangGraph Glossary](../../concepts/low_level.md)
Human-in-the-loop (HIL) interactions are crucial for [agentic systems](../../concepts/agentic_concepts.md#human-in-the-loop). [Breakpoints](../../concepts/low_level.md#breakpoints) are a common HIL interaction pattern, allowing the graph to stop at specific steps and seek human approval before proceeding (e.g., for sensitive actions).
Breakpoints are built on top of LangGraph [checkpoints](../../concepts/low_level.md#persistence), which save the graph's state after each node execution. Checkpoints are saved in [threads](../../concepts/low_level.md#threads) that preserve graph state and can be accessed after a graph has finished execution. This allows for graph execution to pause at specific points, await human approval, and then resume execution from the last checkpoint.
## Setup
@@ -1,6 +1,14 @@
# Review Tool Calls
# How to review tool calls
Human-in-the-loop (HIL) interactions are crucial for [agentic systems](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#human-in-the-loop). A common pattern is to add some human in the loop step after certain tool calls. These tool calls often lead to either a function call or saving of some information. Examples include:
!!! tip "Prerequisites"
This guide assumes familiarity with the following concepts:
* [Tool calling](https://python.langchain.com/docs/concepts/tool_calling/)
* [Human-in-the-loop](../../concepts/human_in_the_loop.md)
* [LangGraph Glossary](../../concepts/low_level.md)
Human-in-the-loop (HIL) interactions are crucial for [agentic systems](../../concepts/agentic_concepts.md). A common pattern is to add some human in the loop step after certain tool calls. These tool calls often lead to either a function call or saving of some information. Examples include:
- A tool call to execute SQL, which will then be run by the tool
- A tool call to generate a summary, which will then be saved to the State of the graph
@@ -11,13 +19,46 @@ There are typically a few different interactions you may want to do here:
1. Approve the tool call and continue
2. Modify the tool call manually and then continue
3. Give natural language feedback, and then pass that back to the agent instead of continuing
3. Give natural language feedback, and then pass that back to the agent
We can implement this in LangGraph using a [breakpoint](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/breakpoints/): breakpoints allow us to interrupt graph execution before a specific step. At this breakpoint, we can manually update the graph state taking one of the three options above
We can implement these in LangGraph using the [`interrupt()`][langgraph.types.interrupt] function. `interrupt` allows us to stop graph execution to collect input from a user and continue execution with collected input:
```python
def human_review_node(state) -> Command[Literal["call_llm", "run_tool"]]:
# this is the value we'll be providing via Command(resume=<human_review>)
human_review = interrupt(
{
"question": "Is this correct?",
# Surface tool calls for review
"tool_call": tool_call
}
)
review_action, review_data = human_review
# Approve the tool call and continue
if review_action == "continue":
return Command(goto="run_tool")
# Modify the tool call manually and then continue
elif review_action == "update":
...
updated_msg = get_updated_msg(review_data)
return Command(goto="run_tool", update={"messages": [updated_message]})
# Give natural language feedback, and then pass that back to the agent
elif review_action == "feedback":
...
feedback_msg = get_feedback_msg(review_data)
return Command(goto="call_llm", update={"messages": [feedback_msg]})
```
## Setup
We are not going to show the full code for the graph we are hosting, but you can see it [here](../../how-tos/human_in_the_loop/review-tool-calls.ipynb#simple-usage) if you want to. Once this graph is hosted, we are ready to invoke it and wait for user input.
We are not going to show the full code for the graph we are hosting, but you can see it [here](../../how-tos/human_in_the_loop/review-tool-calls.ipynb). Once this graph is hosted, we are ready to invoke it and wait for user input.
### SDK initialization
@@ -54,122 +95,9 @@ First, we need to setup our client so that we can communicate with our hosted gr
--data '{}'
```
## Example with no review
Let's look at an example when no review is required (because no tools are called)
=== "Python"
```python
input = { 'messages':[{ "role":"user", "content":"hi!" }] }
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
interrupt_before=["action"],
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const input = { "messages": [{ "role": "user", "content": "hi!" }] };
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: input,
streamMode: "updates",
interruptBefore: ["action"],
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"hi!\"}]},
\"stream_mode\": [
\"updates\"
],
\"interrupt_before\": [\"action\"]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
```
Output:
{'messages': [{'content': 'hi!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '39c51f14-2d5c-4690-883a-d940854b1845', 'example': False}]}
{'messages': [{'content': 'hi!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '39c51f14-2d5c-4690-883a-d940854b1845', 'example': False}, {'content': [{'text': "Hello! Welcome. How can I assist you today? Is there anything specific you'd like to know or any information you're looking for?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-d65e07fb-43ff-4d98-ab6b-6316191b9c8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 355, 'output_tokens': 31, 'total_tokens': 386}}]}
If we check the state, we can see that it is finished
=== "Python"
```python
state = await client.threads.get_state(thread["thread_id"])
print(state['next'])
```
=== "Javascript"
```js
const state = await client.threads.getState(thread["thread_id"]);
console.log(state.next);
```
=== "CURL"
```bash
curl --request GET \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state | jq -c '.next'
```
Output:
[]
## Example of approving tool
Let's now look at what it looks like to approve a tool call. Note that we don't need to pass an interrupt to our streaming calls because the graph (defined [here](../../how-tos/human_in_the_loop/review-tool-calls.ipynb#simple-usage)) was already compiled with an interrupt before the `human_review_node`.
First, let's run the agent with an input that requires tool calls with approval:
=== "Python"
@@ -180,6 +108,7 @@ Let's now look at what it looks like to approve a tool call. Note that we don't
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -195,6 +124,7 @@ Let's now look at what it looks like to approve a tool call. Note that we don't
assistantId,
{
input: input,
streamMode: "updates"
}
);
@@ -213,75 +143,32 @@ Let's now look at what it looks like to approve a tool call. Note that we don't
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]}
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}]}
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}, {'content': [{'text': "Certainly! I can help you check the weather in San Francisco. To get this information, I'll use the weather search function. Let me do that for you right away.", 'type': 'text', 'index': 0}, {'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-45a6b6c3-ac69-42a4-8957-d982203d6392', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 90, 'total_tokens': 450}}]}
{'call_llm': {'messages': [{'content': [{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01142G3woscA8JjFTLdqymtn', 'input': {'city': 'San Francisco'}, 'name': 'weather_search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01Tdfufy4nZYXMbVZvgyNbhc', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 379, 'output_tokens': 66}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-a33434b2-f5ca-40c6-98e2-6288d349d4ce-0', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01142G3woscA8JjFTLdqymtn', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 379, 'output_tokens': 66, 'total_tokens': 445, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'__interrupt__': [{'value': {'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01142G3woscA8JjFTLdqymtn', 'type': 'tool_call'}}, 'resumable': True, 'ns': ['human_review_node:9caf42cf-1371-7213-a331-e6fe5d026be8'], 'when': 'during'}]}
If we now check, we can see that it is waiting on human review:
To approve the tool call, we need to let `human_review_node` know what value to use for the `human_review` variable we defined inside the node. We can provide this value by invoking the graph with a `Command(resume=<human_review>)` input. Since we're approving the tool call, we'll provide `resume` value of `{"action": "continue"}` to navigate to `run_tool` node:
=== "Python"
```python
state = await client.threads.get_state(thread["thread_id"])
# highlight-next-line
from langgraph_sdk.schema import Command
print(state['next'])
```
=== "Javascript"
```js
const state = await client.threads.getState(thread["thread_id"]);
console.log(state.next);
```
=== "CURL"
```bash
curl --request GET \
--url <DELPOYMENT_URL>/threads/<THREAD_ID>/state | jq -c '.next'
```
Output:
['human_review_node']
To approve the tool call, we can just continue the thread with no edits. To do this, we just create a new run with no inputs.
=== "Python"
```python
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=None,
stream_mode="values",
# highlight-next-line
command=Command(resume={"action": "continue"}),
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -294,8 +181,9 @@ To approve the tool call, we can just continue the thread with no edits. To do t
thread["thread_id"],
assistantId,
{
input: null,
streamMode: "values",
// highlight-next-line
command: { resume: { "action": "continue" } },
streamMode: "updates"
}
);
@@ -313,34 +201,21 @@ To approve the tool call, we can just continue the thread with no edits. To do t
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\"
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": { \"action\": \"continue\"}
},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}, {'content': [{'text': "Certainly! I can help you check the weather in San Francisco. To get this information, I'll use the weather search function. Let me do that for you right away.", 'type': 'text', 'index': 0}, {'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-45a6b6c3-ac69-42a4-8957-d982203d6392', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 90, 'total_tokens': 450}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '826cd0f2-9cc6-46f0-b7df-daa6a05d13d2', 'tool_call_id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'artifact': None, 'status': 'success'}]}
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}, {'content': [{'text': "Certainly! I can help you check the weather in San Francisco. To get this information, I'll use the weather search function. Let me do that for you right away.", 'type': 'text', 'index': 0}, {'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-45a6b6c3-ac69-42a4-8957-d982203d6392', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 90, 'total_tokens': 450}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '826cd0f2-9cc6-46f0-b7df-daa6a05d13d2', 'tool_call_id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'artifact': None, 'status': 'success'}, {'content': [{'text': "\n\nGreat news! The weather in San Francisco is sunny today. It's a beautiful day in the city by the bay. Is there anything else you'd like to know about the weather or any other information I can help you with?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5d5fd0f1-a939-447e-801a-9aaa812322d3', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 464, 'output_tokens': 50, 'total_tokens': 514}}]}
{'human_review_node': None}
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_01142G3woscA8JjFTLdqymtn'}]}}
{'call_llm': {'messages': [{'content': "According to the search, it's sunny in San Francisco right now!", 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01JJE9AtT4a9Lob91RRiW9rU', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 458, 'output_tokens': 18}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-5e8d80b5-c46a-4aad-af37-b01f8bb15963-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 458, 'output_tokens': 18, 'total_tokens': 476, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
## Edit Tool Call
@@ -355,7 +230,7 @@ Let's now say we want to edit the tool call. E.g. change some of the parameters
thread["thread_id"],
assistant_id,
input=input,
stream_mode="values",
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -371,7 +246,7 @@ Let's now say we want to edit the tool call. E.g. change some of the parameters
assistantId,
{
input: input,
streamMode: "values",
streamMode: "updates",
}
);
@@ -390,84 +265,35 @@ Let's now say we want to edit the tool call. E.g. change some of the parameters
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]}
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
To do this, we will use `Command` with a different resume value of `{"action": "update", "data": <tool call args>}`. This will do the following:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'cec11391-84da-464b-bd2a-bd4f0d93b9ee', 'example': False}]}
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'cec11391-84da-464b-bd2a-bd4f0d93b9ee', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01SunSpDurNfcnXppWLPrtjC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-6326da9f-6061-4e12-8586-482e32ab4cab', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01SunSpDurNfcnXppWLPrtjC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]}
To do this, we first need to update the state. We can do this by passing a message in with the **same** id of the message we want to overwrite. This will have the effect of **replacing** that old message. Note that this is only possible because of the **reducer** we are using that replaces messages with the same ID - read more about that [here](https://langchain-ai.github.io/langgraph/concepts/low_level/#working-with-messages-in-graph-state).
* combine existing tool call with user-provided tool call arguments and update the existing AI message with the new tool call
* navigate to `run_tool` node with the updated AI message and continue execution
=== "Python"
```python
# To get the ID of the message we want to replace, we need to fetch the current state and find it there.
state = await client.threads.get_state(thread['thread_id'])
print("Current State:")
print(state['values'])
print("\nCurrent Tool Call ID:")
current_content = state['values']['messages'][-1]['content']
current_id = state['values']['messages'][-1]['id']
tool_call_id = state['values']['messages'][-1]['tool_calls'][0]['id']
print(tool_call_id)
# highlight-next-line
from langgraph_sdk.schema import Command
# We now need to construct a replacement tool call.
# We will change the argument to be `San Francisco, USA`
# Note that we could change any number of arguments or tool names - it just has to be a valid one
new_message = {
"role": "assistant",
"content": current_content,
"tool_calls": [
{
"id": tool_call_id,
"name": "weather_search",
"args": {"city": "San Francisco, USA"}
}
],
# This is important - this needs to be the same as the message you replacing!
# Otherwise, it will show up as a separate message
"id": current_id
}
await client.threads.update_state(
# This is the config which represents this thread
thread['thread_id'],
# This is the updated value we want to push
{"messages": [new_message]},
# We push this update acting as our human_review_node
as_node="human_review_node"
)
print("\nResuming Execution")
# Let's now continue executing from here
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=None,
# highlight-next-line
command=Command(
# highlight-next-line
resume={"action": "update", "data": {"city": "San Francisco, USA"}}
# highlight-next-line
),
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -476,51 +302,21 @@ To do this, we first need to update the state. We can do this by passing a messa
=== "Javascript"
```js
const state = await client.threads.getState(thread.thread_id);
console.log("Current State:");
console.log(state.values);
console.log("\nCurrent Tool Call ID:");
const lastMessage = state.values.messages[state.values.messages.length - 1];
const currentContent = lastMessage.content;
const currentId = lastMessage.id;
const toolCallId = lastMessage.tool_calls[0].id;
console.log(toolCallId);
// Construct a replacement tool call
const newMessage = {
role: "assistant",
content: currentContent,
tool_calls: [
{
id: toolCallId,
name: "weather_search",
args: { city: "San Francisco, USA" }
}
],
// Ensure the ID is the same as the message you're replacing
id: currentId
};
await client.threads.updateState(
thread.thread_id, // Thread ID
{
values: { "messages": [newMessage] }, // Updated message
asNode: "human_review_node"
} // Acting as human_review_node
);
console.log("\nResuming Execution");
// Continue executing from here
const streamResponseResumed = client.runs.stream(
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: null,
// highlight-next-line
command: {
// highlight-next-line
resume: { "action": "update", "data": { "city": "San Francisco, USA" } }
// highlight-next-line
},
streamMode: "updates"
}
);
for await (const chunk of streamResponseResumed) {
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
@@ -531,76 +327,37 @@ To do this, we first need to update the state. We can do this by passing a messa
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header 'Content-Type: application/json' \
--data "{
\"values\": { \"messages\": [$(curl --request GET \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state |
jq -c '{
role: "assistant",
content: .values.messages[-1].content,
tool_calls: [
{
id: .values.messages[-1].tool_calls[0].id,
name: "weather_search",
args: { city: "San Francisco, USA" }
}
],
id: .values.messages[-1].id
}')
]},
\"as_node\": \"human_review_node\"
}" && echo "Resuming Execution" && curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data '{
"assistant_id": "agent"
}' | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": { \"action\": \"update\", \"data\": { \"city\": \"San Francisco, USA\" } }
},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
Current State:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '8713d1fa-9b26-4eab-b768-dafdaac70590', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-ede13f26-daf5-4d8f-817a-7611075bbcf1', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]}
Current Tool Call ID:
toolu_01VzagzsUGZsNMwW1wHkcw7h
Resuming Execution
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '8713d1fa-9b26-4eab-b768-dafdaac70590', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-ede13f26-daf5-4d8f-817a-7611075bbcf1', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': None}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '7fc7d463-66bf-4555-9929-6af483de169b', 'tool_call_id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'artifact': None, 'status': 'success'}]}
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '8713d1fa-9b26-4eab-b768-dafdaac70590', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-ede13f26-daf5-4d8f-817a-7611075bbcf1', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': None}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '7fc7d463-66bf-4555-9929-6af483de169b', 'tool_call_id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'artifact': None, 'status': 'success'}, {'content': [{'text': "\n\nBased on the search result, the weather in San Francisco is sunny! It's a beautiful day in the city by the bay. Is there anything else you'd like to know about the weather or any other information I can help you with?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-d90ce97a-39f9-4330-985e-67c5f351a0c5', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 455, 'output_tokens': 52, 'total_tokens': 507}}]}
{'human_review_node': {'messages': [{'role': 'ai', 'content': [{'text': "I'll help you check the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_016L4EDPcaQRzzZxiB4Wq2wa', 'input': {'city': 'San Francisco'}, 'name': 'weather_search', 'type': 'tool_use'}], 'tool_calls': [{'id': 'toolu_016L4EDPcaQRzzZxiB4Wq2wa', 'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}}], 'id': 'run-b07f0c35-4e93-43a5-9b48-363767ada3ca-0'}]}}
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_016L4EDPcaQRzzZxiB4Wq2wa'}]}}
{'call_llm': {'messages': [{'content': "According to the search, it's sunny in San Francisco right now!", 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01De5HurjNUMwMUpfRtMLbX1', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 460, 'output_tokens': 18}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-85e2aaaa-6f61-4fa0-b594-b6e57129d7e7-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 460, 'output_tokens': 18, 'total_tokens': 478, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
## Give feedback to a tool call
Sometimes, you may not want to execute a tool call, but you also may not want to ask the user to manually modify the tool call. In that case it may be better to get natural language feedback from the user. You can then insert these feedback as a mock **RESULT** of the tool call.
Sometimes, you may not want to execute a tool call, but you also may not want to ask the user to manually modify the tool call. In that case it may be better to get natural language feedback from the user. You can then insert this feedback as a mock **RESULT** of the tool call.
There are multiple ways to do this:
You could add a new message to the state (representing the "result" of a tool call)
You could add TWO new messages to the state - one representing an "error" from the tool call, other HumanMessage representing the feedback
Both are similar in that they involve adding messages to the state. The main difference lies in the logic AFTER the `human_node` and how it handles different types of messages.
1. You could add a new message to the state (representing the "result" of a tool call)
2. You could add TWO new messages to the state - one representing an "error" from the tool call, other HumanMessage representing the feedback
For this example we will just add a single tool call representing the feedback. Let's see this in action!
Both are similar in that they involve adding messages to the state. The main difference lies in the logic AFTER the `human_review_node` and how it handles different types of messages.
For this example we will just add a single tool call representing the feedback (see `human_review_node` implementation). Let's see this in action!
=== "Python"
@@ -611,6 +368,7 @@ For this example we will just add a single tool call representing the feedback.
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -626,6 +384,7 @@ For this example we will just add a single tool call representing the feedback.
assistantId,
{
input: input,
streamMode: "updates"
}
);
@@ -644,74 +403,35 @@ For this example we will just add a single tool call representing the feedback.
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]}
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf?\"}]},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
To do this, we will use `Command` with a different resume value of `{"action": "feedback", "data": <feedback string>}`. This will do the following:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'c80f13d0-674d-4233-b6a0-3940509d3cf3', 'example': False}]}
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'c80f13d0-674d-4233-b6a0-3940509d3cf3', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_016XyTdFA8NuPWeLyZPSzoM3', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-4911ac27-3d7c-4edf-a3ca-c2908e3922eb', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_016XyTdFA8NuPWeLyZPSzoM3', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]}
To do this, we first need to update the state. We can do this by passing a message in with the same **tool call id** of the tool call we want to respond to. Note that this is a **different*** ID from above
* create a new tool message that combines existing tool call from LLM with the with user-provided feedback as content
* navigate to `call_llm` node with the updated tool message and continue execution
=== "Python"
```python
# To get the ID of the message we want to replace, we need to fetch the current state and find it there.
state = await client.threads.get_state(thread['thread_id'])
print("Current State:")
print(state['values'])
print("\nCurrent Tool Call ID:")
tool_call_id = state['values']['messages'][-1]['tool_calls'][0]['id']
print(tool_call_id)
# highlight-next-line
from langgraph_sdk.schema import Command
# We now need to construct a replacement tool call.
# We will change the argument to be `San Francisco, USA`
# Note that we could change any number of arguments or tool names - it just has to be a valid one
new_message = {
"role": "tool",
# This is our natural language feedback
"content": "User requested changes: pass in the country as well",
"name": "weather_search",
"tool_call_id": tool_call_id
}
await client.threads.update_state(
# This is the config which represents this thread
thread['thread_id'],
# This is the updated value we want to push
{"messages": [new_message]},
# We push this update acting as our human_review_node
as_node="human_review_node"
)
print("\nResuming execution")
# Let's now continue executing from here
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=None,
stream_mode="values",
# highlight-next-line
command=Command(
resume={
"action": "feedback",
"data": "User requested changes: use <city, country> format for location"
}
),
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -720,133 +440,22 @@ To do this, we first need to update the state. We can do this by passing a messa
=== "Javascript"
```js
const state = await client.threads.getState(thread.thread_id);
console.log("Current State:");
console.log(state.values);
console.log("\nCurrent Tool Call ID:");
const lastMessage = state.values.messages[state.values.messages.length - 1];
const toolCallId = lastMessage.tool_calls[0].id;
console.log(toolCallId);
// Construct a replacement tool call
const newMessage = {
role: "tool",
content: "User requested changes: pass in the country as well",
name: "weather_search",
tool_call_id: toolCallId,
};
await client.threads.updateState(
thread.thread_id, // Thread ID
{
values: { "messages": [newMessage] }, // Updated message
asNode: "human_review_node"
} // Acting as human_review_node
);
console.log("\nResuming Execution");
// Continue executing from here
const streamResponseEdited = client.runs.stream(
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: null,
streamMode: "values",
interruptBefore: ["action"],
// highlight-next-line
command: {
resume: {
"action": "feedback",
"data": "User requested changes: use <city, country> format for location"
}
},
streamMode: "updates"
}
);
for await (const chunk of streamResponseEdited) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header 'Content-Type: application/json' \
--data "{
\"values\": { \"messages\": [$(curl --request GET \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state |
jq -c '{
role: "tool",
content: "User requested changes: pass in the country as well",
name: "get_weather",
tool_call_id: .values.messages[-1].id.tool_calls[0].id
}')
]},
\"as_node\": \"human_review_node\"
}" && echo "Resuming Execution" && curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data '{
"assistant_id": "agent"
}' | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
```
Output:
Current State:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]}
Current Tool Call ID:
toolu_01NNw18j57GEGPZvsa9f1wvX
Resuming execution
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}, {'content': 'User requested changes: pass in the country as well', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '787288be-213c-4fd3-8503-4a009bdb1b00', 'tool_call_id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'artifact': None, 'status': 'success'}, {'content': [{'text': '\n\nI apologize for the oversight. It seems the function requires additional information. Let me try again with a more specific request.', 'type': 'text', 'index': 0}, {'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco, USA"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5c355a56-cfe3-4046-b49f-f5b09fc397ef', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 461, 'output_tokens': 83, 'total_tokens': 544}}]}
We can see that we now get to another breakpoint - because it went back to the model and got an entirely new prediction of what to call. Let's now approve this one and continue
=== "Python"
```python
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=None,
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const streamResponseResumed = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: null,
}
);
for await (const chunk of streamResponseResumed) {
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
@@ -860,31 +469,81 @@ We can see that we now get to another breakpoint - because it went back to the m
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\"
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": { \"action\": \"feedback\", \"data\": \"User requested changes: use <city, country> format for location\" }
},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
{'human_review_node': {'messages': [{'role': 'tool', 'content': 'User requested changes: use <city, country> format for location', 'name': 'weather_search', 'tool_call_id': 'toolu_01RkPHCjpfoUvPAktaq4Cqhm'}]}}
{'call_llm': {'messages': [{'content': [{'text': 'Let me try that again with the correct format:', 'type': 'text'}, {'id': 'toolu_01Rdrag6cVufHZG26BwVaiE7', 'input': {'city': 'San Francisco, USA'}, 'name': 'weather_search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01EBan969yY5f6iGk6sPgKcj', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 469, 'output_tokens': 68}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-64bbc255-d126-4db0-8ae5-3197cf29bed1-0', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01Rdrag6cVufHZG26BwVaiE7', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 469, 'output_tokens': 68, 'total_tokens': 537, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'__interrupt__': [{'value': {'question': 'Is this correct?', 'tool_call': {'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01Rdrag6cVufHZG26BwVaiE7', 'type': 'tool_call'}}, 'resumable': True, 'ns': ['human_review_node:e9856878-e28c-5dd1-d353-4d83aa1a3a2b'], 'when': 'during'}]}
We can see that we now get to another interrupt - because it went back to the model and got an entirely new prediction of what to call. Let's now approve this one and continue.
=== "Python"
```python
# highlight-next-line
from langgraph_sdk.schema import Command
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
# highlight-next-line
command=Command(resume={"action": "continue"}),
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
// highlight-next-line
command: { resume: { "action": "continue" } },
streamMode: "updates"
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": { \"action\": \"continue\"}
},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}, {'content': 'User requested changes: pass in the country as well', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '787288be-213c-4fd3-8503-4a009bdb1b00', 'tool_call_id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'artifact': None, 'status': 'success'}, {'content': [{'text': '\n\nI apologize for the oversight. It seems the function requires additional information. Let me try again with a more specific request.', 'type': 'text', 'index': 0}, {'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco, USA"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5c355a56-cfe3-4046-b49f-f5b09fc397ef', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 461, 'output_tokens': 83, 'total_tokens': 544}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '3b857482-bca2-4a73-a9ab-1f35a3e43e5f', 'tool_call_id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'artifact': None, 'status': 'success'}]}
{'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}, {'content': 'User requested changes: pass in the country as well', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '787288be-213c-4fd3-8503-4a009bdb1b00', 'tool_call_id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'artifact': None, 'status': 'success'}, {'content': [{'text': '\n\nI apologize for the oversight. It seems the function requires additional information. Let me try again with a more specific request.', 'type': 'text', 'index': 0}, {'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco, USA"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5c355a56-cfe3-4046-b49f-f5b09fc397ef', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 461, 'output_tokens': 83, 'total_tokens': 544}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '3b857482-bca2-4a73-a9ab-1f35a3e43e5f', 'tool_call_id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'artifact': None, 'status': 'success'}, {'content': [{'text': "\n\nGreat news! The weather in San Francisco is sunny today. Is there anything else you'd like to know about the weather or any other information I can help you with?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-6a857bb1-f65b-4b86-93d6-c025e003c777', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 557, 'output_tokens': 38, 'total_tokens': 595}}]}
{'human_review_node': None}
{'run_tool': {'messages': [{'role': 'tool', 'name': 'weather_search', 'content': 'Sunny!', 'tool_call_id': 'toolu_01Rdrag6cVufHZG26BwVaiE7'}]}}
{'call_llm': {'messages': [{'content': 'The weather in San Francisco is sunny!', 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_013WTDHhbg8WiYLiQ9n2CaTk', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 550, 'output_tokens': 12}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-b6c815f0-989a-47cf-b150-33e3bbc4eab7-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 550, 'output_tokens': 12, 'total_tokens': 562, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
@@ -1,16 +1,16 @@
# How to Wait for User Input
# How to wait for user input using `interrupt`
One of the main human-in-the-loop interaction patterns is waiting for human input. A key use case involves asking the user clarifying questions. One way to accomplish this is simply go to the `END` node and exit the graph. Then, any user response comes back in as fresh invocation of the graph. This is basically just creating a chatbot architecture.
!!! tip "Prerequisites"
The issue with this is it is tough to resume back in a particular point in the graph. Often times the agent is halfway through some process, and just needs a bit of a user input. Although it is possible to design your graph in such a way where you have a `conditional_entry_point` to route user messages back to the right place, that is not super scalable (as it essentially involves having a routing function that can end up almost anywhere).
This guide assumes familiarity with the following concepts:
A separate way to do this is to have a node explicitly for getting user input. This is easy to implement in a notebook setting - you just put an `input()` call in the node. But that isn't exactly production ready.
* [Human-in-the-loop](../../concepts/human_in_the_loop.md)
* [LangGraph Glossary](../../concepts/low_level.md)
Luckily, LangGraph makes it possible to do similar things in a production way. The basic idea is:
**Human-in-the-loop (HIL)** interactions are crucial for [agentic systems](../../concepts/agentic_concepts.md#human-in-the-loop). Waiting for human input is a common HIL interaction pattern, allowing the agent to ask the user clarifying questions and await input before proceeding.
- Set up a node that represents human input. This can have specific incoming/outgoing edges (as you desire). There shouldn't actually be any logic inside this node.
- Add a breakpoint before the node. This will stop the graph before this node executes (which is good, because there's no real logic in it anyways)
- Use `.update_state` to update the state of the graph. Pass in whatever human response you get. The key here is to use the `as_node` parameter to apply this update **as if you were that node**. This will have the effect of making it so that when you resume execution next it resumes as if that node just acted, and not from the beginning.
We can implement this in LangGraph using the [`interrupt()`][langgraph.types.interrupt] function. `interrupt` allows us to stop graph execution to collect input from a user and continue execution with collected input.
## Setup
@@ -54,7 +54,7 @@ First, we need to setup our client so that we can communicate with our hosted gr
### Initial invocation
Now, let's invoke our graph by interrupting before `ask_human` node:
Now, let's invoke our graph.
=== "Python"
@@ -73,7 +73,6 @@ Now, let's invoke our graph by interrupting before `ask_human` node:
assistant_id,
input=input,
stream_mode="updates",
interrupt_before=["ask_human"],
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
@@ -95,7 +94,6 @@ Now, let's invoke our graph by interrupting before `ask_human` node:
{
input: input,
streamMode: "updates",
interruptBefore: ["ask_human"]
}
);
@@ -115,117 +113,34 @@ Now, let's invoke our graph by interrupting before `ask_human` node:
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Ask the user where they are, then look up the weather there\"}]},
\"interrupt_before\": [\"ask_human\"],
\"stream_mode\": [
\"updates\"
]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
}"
```
Output:
{'agent': {'messages': [{'content': [{'text': "Certainly! I'll use the AskHuman function to ask the user about their location, and then I'll use the search function to look up the weather for that location. Let's start by asking the user where they are.", 'type': 'text'}, {'id': 'toolu_01RFahzYPvnPWTb2USk2RdKR', 'input': {'question': 'Where are you currently located?'}, 'name': 'AskHuman', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-a8422215-71d3-4093-afb4-9db141c94ddb', 'example': False, 'tool_calls': [{'name': 'AskHuman', 'args': {'question': 'Where are you currently located?'}, 'id': 'toolu_01RFahzYPvnPWTb2USk2RdKR'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
{'agent': {'messages': [{'content': [{'text': "I'll help you ask the user about their location and then search for weather information.", 'type': 'text'}, {'id': 'toolu_012JeNEvyePZFWK39d52Wdwi', 'input': {'question': 'Where are you located?'}, 'name': 'AskHuman', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01UBEdS6UvuFMetdokNsykVG', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 438, 'output_tokens': 76}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-1b1210d8-39e0-4607-9f0e-0ea932d28d5c-0', 'example': False, 'tool_calls': [{'name': 'AskHuman', 'args': {'question': 'Where are you located?'}, 'id': 'toolu_012JeNEvyePZFWK39d52Wdwi', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 438, 'output_tokens': 76, 'total_tokens': 514, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'__interrupt__': [{'value': 'Where are you located?', 'resumable': True, 'ns': ['ask_human:2d41f894-f297-211e-9bfe-1d162ecba54a'], 'when': 'during'}]}
You can see that our graph got interrupted inside the `ask_human` node, which is now waiting for a `location` to be provided.
### Adding user input to state
We now want to update this thread with a response from the user. We then can kick off another run.
Because we are treating this as a tool call, we will need to update the state as if it is a response from a tool call. In order to do this, we will need to check the state to get the ID of the tool call.
### Providing human input
We can provide human input (`location`) by invoking the graph with a `Command(resume="<location>")`:
=== "Python"
```python
state = await client.threads.get_state(thread['thread_id'])
tool_call_id = state['values']['messages'][-1]['tool_calls'][0]['id']
# highlight-next-line
from langgraph_sdk.schema import Command
# We now create the tool call with the id and the response we want
tool_message = [{"tool_call_id": tool_call_id, "type": "tool", "content": "san francisco"}]
await client.threads.update_state(thread['thread_id'], {"messages": tool_message}, as_node="ask_human")
```
=== "Javascript"
```js
const state = await client.threads.getState(thread["thread_id"]);
const toolCallId = state.values.messages[state.values.messages.length - 1].tool_calls[0].id;
// We now create the tool call with the id and the response we want
const toolMessage = [
{
tool_call_id: toolCallId,
type: "tool",
content: "san francisco"
}
];
await client.threads.updateState(
thread["thread_id"],
{ values: { messages: toolMessage } },
{ asNode: "ask_human" }
);
```
=== "CURL"
```bash
curl --request GET \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
| jq -r '.values.messages[-1].tool_calls[0].id' \
| sh -c '
TOOL_CALL_ID="$1"
# Construct the JSON payload
JSON_PAYLOAD=$(printf "{\"messages\": [{\"tool_call_id\": \"%s\", \"type\": \"tool\", \"content\": \"san francisco\"}], \"as_node\": \"ask_human\"}" "$TOOL_CALL_ID")
# Send the updated state
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header "Content-Type: application/json" \
--data "${JSON_PAYLOAD}"
' _
```
Output:
{'configurable': {'thread_id': 'a9f322ae-4ed1-41ec-942b-38cb3d342c3a',
'checkpoint_ns': '',
'checkpoint_id': '1ef58e97-a623-63dd-8002-39a9a9b20be3'}}
### Invoking after receiving human input
We can now tell the agent to continue. We can just pass in None as the input to the graph, since no additional input is needed:
=== "Python"
```python
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=None,
# highlight-next-line
command=Command(resume="san francisco"),
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
@@ -238,7 +153,8 @@ We can now tell the agent to continue. We can just pass in None as the input to
thread["thread_id"],
assistantId,
{
input: null,
// highlight-next-line
command: { resume: "san francisco" },
streamMode: "updates"
}
);
@@ -253,40 +169,23 @@ We can now tell the agent to continue. We can just pass in None as the input to
=== "CURL"
```bash
curl --request POST \
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": \"san francisco\"
},
\"stream_mode\": [
\"updates\"
]
}"| \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
}"
```
Output:
{'agent': {'messages': [{'content': [{'text': "Thank you for letting me know that you're in San Francisco. Now, I'll use the search function to look up the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7', 'input': {'query': 'current weather in San Francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-241baed7-db5e-44ce-ac3c-56431705c22b', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in San Francisco'}, 'id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
{'action': {'messages': [{'content': '["I looked up: current weather in San Francisco. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': '8b699b95-8546-4557-8e66-14ea71a15ed8', 'tool_call_id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7'}]}}
{'agent': {'messages': [{'content': "Based on the search results, I can provide you with information about the current weather in San Francisco:\n\nThe weather in San Francisco is currently sunny. It's a beautiful day in the city! \n\nHowever, I should note that the search result included an unusual comment about Gemini zodiac signs. This appears to be either a joke or potentially irrelevant information added by the search engine. For accurate and detailed weather information, you might want to check a reliable weather service or app for San Francisco.\n\nIs there anything else you'd like to know about the weather or San Francisco?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-b4d7309f-f849-46aa-b6ef-475bcabd2be9', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
{'ask_human': {'messages': [{'tool_call_id': 'toolu_012JeNEvyePZFWK39d52Wdwi', 'type': 'tool', 'content': 'san francisco'}]}}
{'agent': {'messages': [{'content': [{'text': 'Let me search for the weather in San Francisco.', 'type': 'text'}, {'id': 'toolu_019f9Y7ST6rNeDQkDjFCHk6C', 'input': {'query': 'current weather in san francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_0152YFm7DtnzfZQuiMUzaSsw', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 527, 'output_tokens': 67}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-f509b5b2-eb30-4200-a8da-fa79ed68812a-0', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in san francisco'}, 'id': 'toolu_019f9Y7ST6rNeDQkDjFCHk6C', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 527, 'output_tokens': 67, 'total_tokens': 594, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'action': {'messages': [{'content': "I looked up: current weather in san francisco. Result: It's sunny in San Francisco, but you better look out if you're a Gemini 😈.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': 'cbd0f623-cc12-48a2-8c18-3cbb943e46e0', 'tool_call_id': 'toolu_019f9Y7ST6rNeDQkDjFCHk6C', 'artifact': None, 'status': 'success'}]}}
{'agent': {'messages': [{'content': "Based on the search results, it's currently sunny in San Francisco. Would you like any specific details about the weather forecast?", 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01FhzXj72CehBYkJGX69vsBc', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 639, 'output_tokens': 29}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-f48e818e-dd88-415e-9a0b-4a958498b553-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 639, 'output_tokens': 29, 'total_tokens': 668, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
@@ -119,11 +119,11 @@ With this set up, running your graph and viewing in LangGraph Studio will result
**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}
![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}
![Configuration modal](img/studio_node_configuration.png){width=1200}
### Playground
@@ -133,7 +133,7 @@ LangGraph Studio also supports prompt engineering through an integration with th
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.
3. Select the LLM run you want to edit. This will open the LangSmith Playground with the selected LLM run.
![Playground in Studio](../img/studio_playground.png){width=1200}
![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.
+1 -1
View File
@@ -1,7 +1,7 @@
# How to stream events
!!! info "Prerequisites"
* [Streaming](../../concepts/streaming.md#streaming-llm-tokens-and-events-astream_events)
* [Streaming](../../concepts/streaming.md#streaming-graph-outputs-stream-and-astream)
This guide covers how to stream events from your graph (`stream_mode="events"`). Depending on the use case and user experience of your LangGraph application, your application may process event types differently.
@@ -1,7 +1,7 @@
# LangGraph Studio With Local Deployment
!!! warning "Browser Compatibility"
Viewing the studio page of a local LangGraph deployment does not work in Safari. Use Chrome instead.
Safari blocks `localhost` connections to Studio. To work around this, start the server with `--tunnel` and youll be able to access Studio from Safari via a secure tunnel.
## Setup
+1 -1
View File
@@ -476,7 +476,7 @@ The `useStream()` hook provides several callback options to help you respond to
- `onError`: Called when an error occurs.
- `onFinish`: Called when the stream is finished.
- `onUpdateEvent`: Called when an update event is received.
- `onCustomEvent`: Called when a custom event is received. See [Custom events](../../concepts/streaming.md#custom) to learn how to stream custom events.
- `onCustomEvent`: Called when a custom event is received. See [Custom events](../../how-tos/streaming.ipynb#custom) to learn how to stream custom events.
- `onMetadataEvent`: Called when a metadata event is received, which contains the Run ID and Thread ID.
## Learn More
@@ -3,7 +3,7 @@
"info": {
"title": "LangGraph Control Plane API (Beta)",
"version": "0.0.1",
"description": "The LangGraph Control Plane API is used to programmatically create and manage LangGraph Server deployments. For example, the APIs can be orchestrated to create custom CI/CD workflows.\n\n### Beta\nThis API is currently in beta and may change or break without notice. This API documentation may not be up-to-date with actual API functionality.\n### Host\nhttps://api.host.langchain.com/\n\n### Authentication\nTo authenticate with the LangGraph Control Plane API, set the `X-Api-Key` header to a valid LangSmith API key for each request.\n\n### Versioning\nEach endpoint path is prefixed with a version (e.g. `v1`).\n\n### Quick Start\n\n1. Call `GET /{version}/projects` to retrieve the `Project` `id`. The `Project` `id` is needed in subsequent API calls.\n2. Call `POST /{version}/projects/{project_id}/revisions` to create a new `Revision` for the `Project`.\n3. Call `GET /{version}/projects/{project_id}/revisions` to get the latest `Revision` (first element in returned list). Get the `Revision` `id`.\n4. Poll for `Revision` `status` until `status` is `DEPLOYED` by calling `GET /{version}/projects/{project_id}/revisions/{revision_id}`."
"description": "The LangGraph Control Plane API is used to programmatically create and manage LangGraph Server deployments. For example, the APIs can be orchestrated to create custom CI/CD workflows.\n\n### Beta\nThis API is currently in beta and may change or break without notice. This API documentation may not be up-to-date with actual API functionality.\n### Host\nhttps://api.host.langchain.com/\n\n### Authentication\nTo authenticate with the LangGraph Control Plane API, set the `X-Api-Key` header to a valid LangSmith API key for each request.\n\n### Versioning\nEach endpoint path is prefixed with a version (e.g. `v1`).\n\n### Quick Start\n\n1. Call `POST /{version}/projects` to create a new `Project`.\n2. Call `GET /{version}/projects` to retrieve the `Project` `id`. The `Project` `id` is needed in subsequent API calls.\n3. Call `POST /{version}/projects/{project_id}/revisions` to create a new `Revision` for the `Project`.\n4. Call `GET /{version}/projects/{project_id}/revisions` to get the latest `Revision` (first element in returned list). Get the `Revision` `id`.\n5. Poll for `Revision` `status` until `status` is `DEPLOYED` by calling `GET /{version}/projects/{project_id}/revisions/{revision_id}`."
},
"servers": [
{
@@ -22,6 +22,34 @@
],
"paths": {
"/v1/projects": {
"post": {
"tags": ["Projects (v1)"],
"summary": "Create Project",
"description": "Create a new project.",
"operationId": "create_project_projects_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateProjectRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Project"
}
}
}
}
}
},
"get": {
"tags": ["Projects (v1)"],
"summary": "List Projects",
@@ -397,6 +425,83 @@
}
}
},
"CreateProjectRequest":{
"type": "object",
"description": "Object for creating a new project.",
"properties": {
"name": {
"type": "string",
"description": "Name of the project.",
"required": true
},
"lc_hosted": {
"type": "boolean",
"description": "Whether the project is hosted on LangChain's cloud (i.e. Cloud SaaS deployment option). Set to `false` for Self-Hosted Data Plane and Self-Hosted Control Plane deployment options.",
"default": true
},
"repo_url": {
"type": ["string", "null"],
"description": "URL of the GitHub repository to use for the project. Omit this field if creating a new project from a Docker image.",
"default": "null"
},
"repo_path": {
"type": ["string", "null"],
"description": "Path to `langgraph.json` configuration file. For example, `langgraph.json` or `src/langgraph.json`.\n\nIf this field is omitted or set to `null`, the previous revision's `repo_path` value is used. Set this field for deployments from a GitHub repository. Omit this field if creating a new revision from a Docker image.",
"default": "null"
},
"repo_commit": {
"type": ["string", "null"],
"description": "Git branch name of deployment.\n\nThis field only applies to deployments from a GitHub repository.",
"default": "null"
},
"env_vars": {
"type": "array",
"description": "List of environment variables or secrets.\n\nIf this field is omitted or set to `null`, the previous revision's `env_vars` value is used.",
"items": {
"$ref": "#/components/schemas/EnvVar"
},
"default": "null"
},
"host_integration_id": {
"type": ["string", "null"],
"format": "uuid",
"description": "Do not use."
},
"deployment_type": {
"type": "string",
"description": "Development (`dev`) or Production (`prod`) type deployment.",
"enum": [
"dev",
"prod"
]
},
"shareable": {
"type": ["boolean", "null"],
"description": "Boolean flag to configure if a deployment is shareable through LangGraph Studio.\n\nIf this field is omitted or set to `null`, the previous revision's `shareable` value is used. This field does not apply to BYOC deployments.",
"default": "null"
},
"platform": {
"type": "object",
"description": "Do not use.",
"default": "null"
},
"image_path": {
"type": ["string", "null"],
"description": "URI of the Docker image to deploy.\n\nIf this field is omitted or set to `null`, the previous revision's `image_path` value is used. Set this field for BYOC deployments. Omit this field if creating a new revision from a GitHub repository.",
"default": "null"
},
"build_on_push": {
"type": "boolean",
"description": "Boolean flag to indicate if a new revision is automatically created on push to GitHub branch (`repo_branch`).\n\nThis field does not apply for BYOC deployments.",
"default": false
},
"container_spec": {
"description": "If this field is omitted or set to `null`, the previous revision's `container_spec` value is used.",
"$ref": "#/components/schemas/ContainerSpec",
"default": "null"
}
}
},
"CreateRevisionRequest": {
"type": "object",
"description": "Object for creating a new revision.",
+10 -3
View File
@@ -10,9 +10,6 @@ The LangGraph command line interface includes commands to build and run a LangGr
=== "Python"
```bash
pip install langgraph-cli
# Install via Homebrew
brew install langgraph-cli
```
=== "JS"
@@ -298,6 +295,11 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| `--no-reload` | | Disable auto-reload |
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
| `--debug-port INTEGER` | | Port for debugger to listen on |
| `--wait-for-client` | `False` | Wait for a debugger client to connect to the debug port before starting the server |
| `--no-browser` | | Skip automatically opening the browser when the server starts |
| `--studio-url TEXT` | | URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com |
| `--allow-blocking` | `False` | Do not raise errors for synchronous I/O blocking operations in your code (added in `0.2.6`) |
| `--tunnel` | `False` | Expose the local server via a public tunnel (Cloudflare) for remote frontend access. This avoids issues with browsers like Safari or networks blocking localhost connections |
| `--help` | | Display command documentation |
@@ -321,6 +323,11 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| `--no-reload` | | Disable auto-reload |
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
| `--debug-port INTEGER` | | Port for debugger to listen on |
| `--wait-for-client` | `False` | Wait for a debugger client to connect to the debug port before starting the server |
| `--no-browser` | | Skip automatically opening the browser when the server starts |
| `--studio-url TEXT` | | URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com |
| `--allow-blocking` | `False` | Do not raise errors for synchronous I/O blocking operations in your code |
| `--tunnel` | `False` | Expose the local server via a public tunnel (Cloudflare) for remote frontend access. This avoids issues with browsers or networks blocking localhost connections |
| `--help` | | Display command documentation |
### `build`
+18
View File
@@ -97,3 +97,21 @@ Database Connectivity:
Custom Redis instances are only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployments.
Specify `REDIS_URI_CUSTOM` to use a custom Redis instance. The value of `REDIS_URI_CUSTOM` must be a valid [Redis connection URI](https://redis-py.readthedocs.io/en/stable/connections.html#redis.Redis.from_url).
## `REDIS_KEY_PREFIX`
!!! info "Available in API Server version 0.1.9+"
This environment variable is supported in API Server version 0.1.9 and above.
Specify a prefix for Redis keys. This allows multiple LangGraph Server instances to share the same Redis instance by using different key prefixes.
Defaults to `''`.
## `REDIS_CLUSTER`
!!! info "Only Allowed in Self-Hosted Deployments"
Redis Cluster mode is only available in Self-Hosted Deployment models, LangGraph Cloud SaaS will provision a redis instance for you by default.
Set `REDIS_CLUSTER` to `True` to enable Redis Cluster mode. When enabled, the system will connect to Redis using cluster mode. This is useful when connecting to a Redis Cluster deployment.
Defaults to `False`.
+6 -6
View File
@@ -15,7 +15,7 @@ This guide shows a typical structure for a LangGraph application and shows how t
To deploy using the LangGraph Platform, the following information should be provided:
1. A [LangGraph API Configuration file](#configuration-file) (`langgraph.json`) that specifies the dependencies, graphs, environment variables to use for the application.
1. A [LangGraph API Configuration file](#configuration-file-concepts) (`langgraph.json`) that specifies the dependencies, graphs, environment variables to use for the application.
2. The [graphs](#graphs) that implement the logic of the application.
3. A file that specifies [dependencies](#dependencies) required to run the application.
4. [Environment variable](#environment-variables) that are required for the application to run.
@@ -77,7 +77,7 @@ Below are examples of directory structures for Python and JavaScript application
The directory structure of a LangGraph application can vary depending on the programming language and the package manager used.
## Configuration File
## Configuration File {#configuration-file-concepts}
The `langgraph.json` file is a JSON file that specifies the dependencies, graphs, environment variables, and other settings required to deploy a LangGraph application.
@@ -145,18 +145,18 @@ A LangGraph application may depend on other Python packages or JavaScript librar
You will generally need to specify the following information for dependencies to be set up correctly:
1. A file in the directory that specifies the dependencies (e.g., `requirements.txt`, `pyproject.toml`, or `package.json`).
2. A `dependencies` key in the [LangGraph configuration file](#configuration-file) that specifies the dependencies required to run the LangGraph application.
3. Any additional binaries or system libraries can be specified using `dockerfile_lines` key in the [LangGraph configuration file](#configuration-file).
2. A `dependencies` key in the [LangGraph configuration file](#configuration-file-concepts) that specifies the dependencies required to run the LangGraph application.
3. Any additional binaries or system libraries can be specified using `dockerfile_lines` key in the [LangGraph configuration file](#configuration-file-concepts).
## Graphs
Use the `graphs` key in the [LangGraph configuration file](#configuration-file) to specify which graphs will be available in the deployed LangGraph application.
Use the `graphs` key in the [LangGraph configuration file](#configuration-file-concepts) to specify which graphs will be available in the deployed LangGraph application.
You can specify one or more graphs in the configuration file. Each graph is identified by a name (which should be unique) and a path for either: (1) the compiled graph or (2) a function that makes a graph is defined.
## Environment Variables
If you're working with a deployed LangGraph application locally, you can configure environment variables in the `env` key of the [LangGraph configuration file](#configuration-file).
If you're working with a deployed LangGraph application locally, you can configure environment variables in the `env` key of the [LangGraph configuration file](#configuration-file-concepts).
For a production deployment, you will typically want to configure the environment variables in the deployment environment.
-33
View File
@@ -409,39 +409,6 @@ The `Command` primitive provides several options to control and modify the graph
By leveraging `Command`, you can resume graph execution, handle user inputs, and dynamically adjust the graph's state.
## Using with `invoke` and `ainvoke`
When you use `stream` or `astream` to run the graph, you will receive an `Interrupt` event that let you know the `interrupt` was triggered.
`invoke` and `ainvoke` do not return the interrupt information. To access this information, you must use the [get_state](../reference/graphs.md#langgraph.graph.graph.CompiledGraph.get_state) method to retrieve the graph state after calling `invoke` or `ainvoke`.
```python
# Run the graph up to the interrupt
result = graph.invoke(inputs, thread_config)
# Get the graph state to get interrupt information.
state = graph.get_state(thread_config)
# Print the state values
print(state.values)
# Print the pending tasks
print(state.tasks)
# Resume the graph with the user's input.
graph.invoke(Command(resume={"age": "25"}), thread_config)
```
```pycon
{'foo': 'bar'} # State values
(
PregelTask(
id='5d8ffc92-8011-0c9b-8b59-9d3545b7e553',
name='node_foo',
path=('__pregel_pull', 'node_foo'),
error=None,
interrupts=(Interrupt(value='value_in_interrupt', resumable=True, ns=['node_foo:5d8ffc92-8011-0c9b-8b59-9d3545b7e553'], when='during'),), state=None,
result=None
),
) # Pending tasks. interrupts
```
## How does resuming from an interrupt work?
!!! warning
+1 -1
View File
@@ -20,7 +20,7 @@ The LangGraph Platform incorporates best practices for agent deployment, so you
* **Double texting support**: Many times users might interact with your graph in unintended ways. For instance, a user may send one message and before the graph has finished running send a second message. We call this ["double texting"](double_texting.md) and have added four different ways to handle this.
* **Optimized checkpointer**: LangGraph Platform comes with a built-in [checkpointer](./persistence.md#checkpoints) optimized for LangGraph applications.
* **Human-in-the-loop endpoints**: We've exposed all endpoints needed to support [human-in-the-loop](human_in_the_loop.md) features.
* **Memory**: In addition to thread-level persistence (covered above by [checkpointers]l(./persistence.md#checkpoints)), LangGraph Platform also comes with a built-in [memory store](persistence.md#memory-store).
* **Memory**: In addition to thread-level persistence (covered above by [checkpointers](./persistence.md#checkpoints)), LangGraph Platform also comes with a built-in [memory store](persistence.md#memory-store).
* **Cron jobs**: Built-in support for scheduling tasks, enabling you to automate regular actions like data clean-up or batch processing within your applications.
* **Webhooks**: Allows your application to send real-time notifications and data updates to external systems, making it easy to integrate with third-party services and trigger actions based on specific events.
* **Monitoring**: LangGraph Server integrates seamlessly with the [LangSmith](https://docs.smith.langchain.com/) monitoring platform, providing real-time insights into your application's performance and health.
+6 -8
View File
@@ -7,19 +7,17 @@
## Versions
There are two versions of the self-hosted deployment: [Self-Hosted Enterprise](./deployment_options.md#self-hosted-enterprise) and [Self-Hosted Lite](./deployment_options.md#self-hosted-lite).
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
### Self-Hosted Lite
### Self-Hosted Data Plane
The Self-Hosted Lite version is a limited version of LangGraph Platform that you can run locally or in a self-hosted manner (up to 1 million nodes executed per year).
The [Self-Hosted Data Plane](./langgraph_self_hosted_data_plane.md) deployment option is a "hybrid" model for deployment where we manage the [control plane](./langgraph_control_plane.md) in our cloud and you manage the [data plane](./langgraph_data_plane.md) in your cloud. This option provides a way to securely manage your data plane infrastructure, while offloading control plane management to us.
When using the Self-Hosted Lite version, you authenticate with a [LangSmith](https://smith.langchain.com/) API key.
When using the Self-Hosted Data Plane version, you authenticate with a [LangSmith](https://smith.langchain.com/) API key.
### Self-Hosted Enterprise
### Self-Hosted Control Plane
The Self-Hosted Enterprise version is the full version of LangGraph Platform.
To use the Self-Hosted Enterprise version, you must acquire a license key that you will need to pass in when running the Docker image. To acquire a license key, please email sales@langchain.dev.
The [Self-Hosted Control Plane](./langgraph_self_hosted_control_plane.md) deployment option is a fully self-hosted model for deployment where you manage the [control plane](./langgraph_control_plane.md) and [data plane](./langgraph_data_plane.md) in your cloud. This option give you full control and responsibility of the control plane and data plane infrastructure.
## Requirements
+1 -1
View File
@@ -39,7 +39,7 @@ You will eventually need to pass in the following environment variables to the L
`<database_name_1>` and `database_name_2` are different databases within the same instance, but `<hostname_1>` is shared. **The same database cannot be used for separate deployments**.
- `LANGSMITH_API_KEY`: (If using [Self-Hosted Lite](../concepts/deployment_options.md#self-hosted-lite)) LangSmith API key. This will be used to authenticate ONCE at server start up.
- `LANGSMITH_API_KEY`: (If using [Self-Hosted Data Plane](../concepts/deployment_options.md#self-hosted-data-plane)) LangSmith API key. This will be used to authenticate ONCE at server start up.
- `LANGGRAPH_CLOUD_LICENSE_KEY`: (If using [Self-Hosted Enterprise](../concepts/deployment_options.md#self-hosted-enterprise)) LangGraph Platform license key. This will be used to authenticate ONCE at server start up.
- `LANGCHAIN_ENDPOINT`: To send traces to a [self-hosted LangSmith](https://docs.smith.langchain.com/self_hosting) instance, set `LANGCHAIN_ENDPOINT` to the hostname of the self-hosted LangSmith instance.
File diff suppressed because one or more lines are too long
+11 -5
View File
@@ -249,7 +249,7 @@ LangGraph Platform supports multiple types of runs besides streaming runs.
- [How to create cron jobs](../cloud/how-tos/cron_jobs.md)
- [How to create stateless runs](../cloud/how-tos/stateless_runs.md)
### Streaming
### Streaming {#streaming_1}
Streaming the results of your LLM application is vital for ensuring a good user experience, especially when your graph may call multiple models and take a long time to fully complete a run. Read about how to stream values from your graph in these how to guides:
@@ -267,15 +267,21 @@ With LangGraph Platform you can integrate LangGraph agents into your React appli
- [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
### Human-in-the-loop {#human-in-the-loop-1}
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.
[Human-in-the-loop](../concepts/human_in_the_loop.md) functionality allows
you to involve humans in the decision-making process of your graph. These how-to guides show how to implement human-in-the-loop workflows in your graph.
- [How to add a breakpoint](../cloud/how-tos/human_in_the_loop_breakpoint.md)
- [How to wait for user input](../cloud/how-tos/human_in_the_loop_user_input.md)
- [How to review tool calls](../cloud/how-tos/human_in_the_loop_review_tool_calls.md)
- [How to add static breakpoints](../cloud/how-tos/human_in_the_loop_breakpoint.md)
### Time Travel
[Time travel](../concepts/time-travel.md) allows you to replay past actions in your LangGraph application to explore alternative paths and debug issues. These how-to guides show how to use time travel in your graph.
- [How to edit graph state](../cloud/how-tos/human_in_the_loop_edit_state.md)
- [How to replay and branch from prior states](../cloud/how-tos/human_in_the_loop_time_travel.md)
- [How to review tool calls](../cloud/how-tos/human_in_the_loop_review_tool_calls.md)
### Double-texting
@@ -193,7 +193,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Using in `create_react_agent`\n",
"## Using in `create_react_agent` {#using-in-create-react-agent}\n",
"\n",
"Add semantic search to your tool calling agent by injecting the store in the `prompt` function. You can also use the store in a tool to let your agent manually store or search for memories."
]
+3
View File
@@ -0,0 +1,3 @@
.safari {
color: #0070C9;
}
@@ -14,3 +14,4 @@ Errors referenced below will have an `lc_error_code` property corresponding to o
These guides provide troubleshooting information for errors that are specific to the LangGraph Platform.
- [INVALID_LICENSE](./INVALID_LICENSE.md)
- [Studio Errors](../studio.md)
+45
View File
@@ -0,0 +1,45 @@
# Troubleshooting LangGraph Studio
## :fontawesome-brands-safari:{ .safari } Safari connection error with local dev server
Safari blocks plainHTTP traffic on localhost. If you start Studio with a vanilla
`langgraph dev`, the page may report a "Failed to load assistants" error (or something similar) and the browser DevTools will show network errors.
#### Quick fix — run Studio through a secure Cloudflare tunnel
=== "Python"
```shell
pip install -U langgraph-cli>=0.2.6 # Python
langgraph dev --tunnel
```
=== "JS"
```shell
# Requires @langchain/langgraph-cli>=0.0.26
npx @langchain/langgraph-cli dev
```
The command prints a URL like:
```shell
https://smith.langchain.com/studio/?baseUrl=https://hamilton-praise-heart-costumes.trycloudflare.com
```
where
```shell
?baseUrl=https://hamilton-praise-heart-costumes.trycloudflare.com
```
indicates the endpoint where your agent server is exposed.
Open that URL in Safari and Studio should load immediately.
#### Alternative — use a Chromiumbased browser
Chrome, Edge, and Brave allow HTTP on localhost, so a plain `langgraph dev` should work without extra steps.
#### If its still not loading
1. Make sure the `baseUrl` query parameter in the studio URL points to the **tunnel URL** NOT to localhost.
2. Confirm your CLI version with `langgraph --version`.
No other configuration, certificates, or CORS tweaks are required.
+1 -1
View File
@@ -139,7 +139,7 @@ langgraph dev --no-browser
}
```
Now let's try to chat with our bot. If we've implemented authentication correctly, we should only be able to access the bot if we provide a valid token in the request header. Users will still, however, be able to access each other's resources until we add [resource authorization handlers](../../concepts/auth.md#resource-authorization) in the next section of our tutorial.
Now let's try to chat with our bot. If we've implemented authentication correctly, we should only be able to access the bot if we provide a valid token in the request header. Users will still, however, be able to access each other's resources until we add [resource authorization handlers](../../concepts/auth.md#resource-specific-handlers) in the next section of our tutorial.
![Authentication, no authorization handlers](./img/authentication.png)
+4 -4
View File
@@ -6,7 +6,7 @@
2. Resource Authorization (you are here) - Let users have private conversations
3. [Production Auth](add_auth_server.md) - Add real user accounts and validate using OAuth2
In this tutorial, we will extend our chatbot to give each user their own private conversations. We'll add [resource-level access control](../../concepts/auth.md#resource-level-access-control) so users can only see their own threads.
In this tutorial, we will extend our chatbot to give each user their own private conversations. We'll add [resource-level access control](../../concepts/auth.md#single-owner-resources) so users can only see their own threads.
![Authorization handlers](./img/authorization.png)
@@ -35,7 +35,7 @@ langgraph dev --no-browser
Recall that in the last tutorial, the [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object let us register an [authentication function](../../concepts/auth.md#authentication), which the LangGraph platform uses to validate the bearer tokens in incoming requests. Now we'll use it to register an **authorization** handler.
Authorization handlers are functions that run **after** authentication succeeds. These handlers can add [metadata](../../concepts/auth.md#resource-metadata) to resources (like who owns them) and filter what each user can see.
Authorization handlers are functions that run **after** authentication succeeds. These handlers can add [metadata](../../concepts/auth.md#filter-operations) to resources (like who owns them) and filter what each user can see.
Let's update our `src/security/auth.py` and add one authorization handler that is run on every request:
@@ -211,7 +211,7 @@ This means:
## Adding scoped authorization handlers {#scoped-authorization}
The broad `@auth.on` handler matches on all [authorization events](../../concepts/auth.md#authorization-events). This is concise, but it means the contents of the `value` dict are not well-scoped, and we apply the same user-level access control to every resource. If we want to be more fine-grained, we can also control specific actions on resources.
The broad `@auth.on` handler matches on all [authorization events](../../concepts/auth.md#supported-resources). This is concise, but it means the contents of the `value` dict are not well-scoped, and we apply the same user-level access control to every resource. If we want to be more fine-grained, we can also control specific actions on resources.
Update `src/security/auth.py` to add handlers for specific resource types:
@@ -290,7 +290,7 @@ Notice that instead of one global handler, we now have specific handlers for:
2. Reading threads
3. Accessing assistants
The first three of these match specific **actions** on each resource (see [resource actions](../../concepts/auth.md#resource-actions)), while the last one (`@auth.on.assistants`) matches _any_ action on the `assistants` resource. For each request, LangGraph will run the most specific handler that matches the resource and action being accessed. This means that the four handlers above will run rather than the broadly scoped "`@auth.on`" handler.
The first three of these match specific **actions** on each resource (see [resource actions](../../concepts/auth.md#resource-specific-handlers)), while the last one (`@auth.on.assistants`) matches _any_ action on the `assistants` resource. For each request, LangGraph will run the most specific handler that matches the resource and action being accessed. This means that the four handlers above will run rather than the broadly scoped "`@auth.on`" handler.
Try adding the following test code to your test file:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1627,7 +1627,7 @@
},
{
"cell_type": "code",
"execution_count": 83,
"execution_count": null,
"metadata": {},
"outputs": [
{
+7 -43
View File
@@ -1,5 +1,5 @@
site_name: ""
site_description: Build language agents as graphs
site_name: "LangGraph"
site_description: Build reliable, stateful AI systems, without giving up control
site_url: https://langchain-ai.github.io/langgraph/
repo_url: https://github.com/langchain-ai/langgraph
edit_uri: edit/main/docs/docs/
@@ -104,23 +104,19 @@ nav:
- How-to Guides:
- how-tos/index.md
- LangGraph:
- LangGraph: how-tos#langgraph
- Graph API Basics:
- Graph API Basics: how-tos#graph-api-basics
- how-tos/state-reducers.ipynb
- how-tos/sequence.ipynb
- how-tos/branching.ipynb
- how-tos/recursion-limit.ipynb
- how-tos/visualization.ipynb
- Controllability:
- Controllability: how-tos#controllability
- how-tos/map-reduce.ipynb
- how-tos/command.ipynb
- how-tos/configuration.ipynb
- how-tos/node-retries.ipynb
- how-tos/return-when-recursion-limit-hits.ipynb
- Persistence:
- Persistence: how-tos#persistence
- how-tos/persistence.ipynb
- how-tos/subgraph-persistence.ipynb
- how-tos/cross-thread-persistence.ipynb
@@ -130,13 +126,11 @@ nav:
- how-tos/persistence-functional.ipynb
- how-tos/cross-thread-persistence-functional.ipynb
- Memory:
- Memory: how-tos#memory
- how-tos/memory/manage-conversation-history.ipynb
- how-tos/memory/delete-messages.ipynb
- how-tos/memory/add-summary-conversation-history.ipynb
- how-tos/memory/semantic-search.ipynb
- Human-in-the-loop:
- Human-in-the-loop: how-tos#human-in-the-loop
- how-tos/human_in_the_loop/breakpoints.ipynb
- how-tos/human_in_the_loop/dynamic_breakpoints.ipynb
- how-tos/human_in_the_loop/edit-graph-state.ipynb
@@ -146,7 +140,6 @@ nav:
- how-tos/wait-user-input-functional.ipynb
- how-tos/review-tool-calls-functional.ipynb
- Streaming:
- Streaming: how-tos#streaming
- how-tos/streaming.ipynb
- how-tos/streaming-tokens.ipynb
- how-tos/streaming-specific-nodes.ipynb
@@ -154,7 +147,6 @@ nav:
- how-tos/streaming-subgraphs.ipynb
- how-tos/disable-streaming.ipynb
- Tool calling:
- Tool calling: how-tos#tool-calling
- how-tos/tool-calling.ipynb
- how-tos/tool-calling-errors.ipynb
- how-tos/pass-run-time-values-to-tools.ipynb
@@ -162,31 +154,26 @@ nav:
- how-tos/pass-config-to-tools.ipynb
- how-tos/many-tools.ipynb
- Subgraphs:
- Subgraphs: how-tos#subgraphs
- how-tos/subgraph.ipynb
- how-tos/subgraphs-manage-state.ipynb
- how-tos/subgraph-transform-state.ipynb
- Multi-agent:
- Multi-agent: how-tos#multi-agent
- how-tos/agent-handoffs.ipynb
- how-tos/multi-agent-network.ipynb
- how-tos/multi-agent-multi-turn-convo.ipynb
- how-tos/multi-agent-network-functional.ipynb
- how-tos/multi-agent-multi-turn-convo-functional.ipynb
- State Management:
- State Management: how-tos#state-management
- how-tos/state-model.ipynb
- how-tos/input_output_schema.ipynb
- how-tos/pass_private_state.ipynb
- Other:
- Other: how-tos#other
- how-tos/async.ipynb
- how-tos/react-agent-structured-output.ipynb
- how-tos/run-id-langsmith.ipynb
- how-tos/autogen-integration.ipynb
- how-tos/autogen-integration-functional.ipynb
- Prebuilt ReAct Agent:
- Prebuilt ReAct Agent: how-tos#prebuilt-react-agent
- how-tos/create-react-agent.ipynb
- how-tos/create-react-agent-memory.ipynb
- how-tos/create-react-agent-system-prompt.ipynb
@@ -196,9 +183,7 @@ nav:
- how-tos/react-agent-from-scratch.ipynb
- how-tos/react-agent-from-scratch-functional.ipynb
- LangGraph Platform:
- LangGraph Platform: how-tos#langgraph-platform
- Application Structure:
- Application Structure: how-tos#application-structure
- cloud/deployment/setup.md
- cloud/deployment/setup_pyproject.md
- cloud/deployment/setup_javascript.md
@@ -208,7 +193,6 @@ nav:
- cloud/deployment/graph_rebuild.md
- how-tos/autogen-langgraph-platform.ipynb
- Deployment:
- Deployment: how-tos#deployment
- cloud/deployment/cloud.md
- cloud/deployment/self_hosted_data_plane.md
- cloud/deployment/self_hosted_control_plane.md
@@ -219,25 +203,20 @@ nav:
- Data Management:
- how-tos/ttl/configure_ttl.md
- Authentication & Access Control:
- Authentication & Access Control: how-tos#authentication-access-control
- how-tos/auth/custom_auth.md
- how-tos/auth/openapi_security.md
- Assistants:
- Assistants: how-tos#assistants
- cloud/how-tos/configuration_cloud.md
- cloud/how-tos/assistant_versioning.md
- Threads:
- Threads: how-tos#threads
- cloud/how-tos/copy_threads.md
- cloud/how-tos/check_thread_status.md
- Runs:
- Runs: how-tos#runs
- cloud/how-tos/background_run.md
- cloud/how-tos/same-thread.md
- cloud/how-tos/cron_jobs.md
- cloud/how-tos/stateless_runs.md
- Streaming:
- Streaming: how-tos#streaming_1
- cloud/how-tos/stream_values.md
- cloud/how-tos/stream_updates.md
- cloud/how-tos/stream_messages.md
@@ -247,14 +226,12 @@ nav:
- 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
- cloud/how-tos/human_in_the_loop_user_input.md
- cloud/how-tos/human_in_the_loop_edit_state.md
- cloud/how-tos/human_in_the_loop_time_travel.md
- cloud/how-tos/human_in_the_loop_review_tool_calls.md
- Double-texting:
- Double-texting: how-tos#double-texting
- cloud/how-tos/interrupt_concurrent.md
- cloud/how-tos/rollback_concurrent.md
- cloud/how-tos/reject_concurrent.md
@@ -264,12 +241,10 @@ nav:
- Cron Jobs:
- cloud/how-tos/cron_jobs.md
- Modifying the API:
- Modifying the API: how-tos#modifying-the-api
- how-tos/http/custom_lifespan.md
- how-tos/http/custom_middleware.md
- how-tos/http/custom_routes.md
- LangGraph Studio:
- LangGraph Studio: how-tos#langgraph-studio
- cloud/how-tos/test_deployment.md
- cloud/how-tos/test_local_deployment.md
- cloud/how-tos/invoke_studio.md
@@ -281,7 +256,6 @@ nav:
- Concepts:
- concepts/index.md
- LangGraph:
- LangGraph: concepts#langgraph
- concepts/high_level.md
- concepts/low_level.md
- concepts/agentic_concepts.md
@@ -297,9 +271,7 @@ nav:
- concepts/durable_execution.md
- concepts/pregel.md
- LangGraph Platform:
- LangGraph Platform: concepts#langgraph-platform
- High Level:
- High Level: concepts#high-level
- concepts/langgraph_platform.md
- concepts/platform_architecture.md
- concepts/scalability_and_resilience.md
@@ -308,7 +280,6 @@ nav:
- concepts/plans.md
- concepts/template_applications.md
- Components:
- Components: concepts#components
- concepts/langgraph_control_plane.md
- concepts/langgraph_data_plane.md
- concepts/langgraph_server.md
@@ -317,13 +288,11 @@ nav:
- concepts/sdk.md
- how-tos/use-remote-graph.md
- LangGraph Server:
- LangGraph Server: concepts#langgraph-server
- concepts/application_structure.md
- concepts/assistants.md
- concepts/double_texting.md
- concepts/auth.md
- Deployment Options:
- Deployment Options: concepts#deployment-options
- concepts/langgraph_cloud.md
- concepts/langgraph_self_hosted_data_plane.md
- concepts/langgraph_self_hosted_control_plane.md
@@ -332,18 +301,15 @@ nav:
- Tutorials:
- tutorials/index.md
- Quick Start:
- Quick Start: tutorials#quick-start
- tutorials/introduction.ipynb
- tutorials/workflows/index.md
- tutorials/langgraph-platform/local-server.md
- cloud/quick_start.md
- Chatbots:
- Chatbots: tutorials#chatbots
- tutorials/customer-support/customer-support.ipynb
- tutorials/chatbots/information-gather-prompting.ipynb
- tutorials/code_assistant/langgraph_code_assistant.ipynb
- RAG:
- RAG: tutorials#rag
- tutorials/rag/langgraph_adaptive_rag.ipynb
- tutorials/rag/langgraph_adaptive_rag_local.ipynb
- tutorials/rag/langgraph_agentic_rag.ipynb
@@ -353,37 +319,30 @@ nav:
- tutorials/rag/langgraph_self_rag_local.ipynb
- tutorials/sql-agent.ipynb
- Agent Architectures:
- Agent Architectures: tutorials#agent-architectures
- Multi-Agent Systems:
- Multi-Agent Systems: tutorials#multi-agent-systems
- tutorials/multi_agent/multi-agent-collaboration.ipynb
- tutorials/multi_agent/agent_supervisor.ipynb
- tutorials/multi_agent/hierarchical_agent_teams.ipynb
- Planning Agents:
- Planning Agents: tutorials#planning-agents
- tutorials/plan-and-execute/plan-and-execute.ipynb
- tutorials/rewoo/rewoo.ipynb
- tutorials/llm-compiler/LLMCompiler.ipynb
- Reflection & Critique:
- Reflection & Critique: tutorials#reflection-critique
- tutorials/reflection/reflection.ipynb
- tutorials/reflexion/reflexion.ipynb
- tutorials/tot/tot.ipynb
- tutorials/lats/lats.ipynb
- tutorials/self-discover/self-discover.ipynb
- Evaluation & Analysis:
- Evaluation & Analysis: tutorials#evaluation
- tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb
- tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb
- Experimental:
- Experimental: tutorials#experimental
- tutorials/storm/storm.ipynb
- tutorials/tnt-llm/tnt-llm.ipynb
- tutorials/web-navigation/web_voyager.ipynb
- tutorials/usaco/usaco.ipynb
- tutorials/extraction/retries.ipynb
- LangGraph Platform:
- LangGraph Platform: concepts#langgraph-platform
- tutorials/auth/getting_started.md
- tutorials/auth/resource_auth.md
- tutorials/auth/add_auth_server.md
@@ -400,6 +359,7 @@ nav:
- troubleshooting/errors/MULTIPLE_SUBGRAPHS.md
- troubleshooting/errors/INVALID_CHAT_HISTORY.md
- troubleshooting/errors/INVALID_LICENSE.md
- troubleshooting/studio.md
- LangGraph Academy Course: https://academy.langchain.com/courses/intro-to-langgraph
- Agents:
@@ -532,6 +492,9 @@ extra:
data: 0
note: >-
Thanks for your feedback! Please help us improve this page by adding to the discussion below.
shared_analytics:
provider: google
property: G-47WX3HKKY2
validation:
# https://www.mkdocs.org/user-guide/configuration/
# We are still raising for omitted files because they determine the breadcrumbs for pages.
@@ -549,3 +512,4 @@ copyright: >
Copyright &copy; 2025 LangChain, Inc | <a href="#__consent">Consent Preferences</a>
extra_css:
- stylesheets/version_admonitions.css
- stylesheets/logos.css
+2073 -1622
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -21,17 +21,17 @@ langgraph-checkpoint-sqlite = { path = "../libs/checkpoint-sqlite", develop = tr
langgraph-checkpoint-postgres = { path = "../libs/checkpoint-postgres", develop = true }
langgraph-sdk = {path = "../libs/sdk-py", develop = true}
langchain-ollama = "^0.2.3"
mkdocs = "^1.6.0"
mkdocs-autorefs = ">=1.0.1,<1.1.0"
mkdocstrings = "^0.25.1"
mkdocstrings-python = "^1.10.4"
mkdocs-minify-plugin = "^0.8.0"
mkdocs-rss-plugin = "^1.13.1"
mkdocs-git-committers-plugin-2 = "^2.3.0"
mkdocs-material = {extras = ["imaging"], version = "^9.5.27"}
markdown-callouts = "^0.4.0"
markdown-include = "^0.8.1"
mkdocs-exclude = "^1.0.2"
mkdocs = "*"
mkdocs-autorefs = "*"
mkdocstrings = "*"
mkdocstrings-python = "*"
mkdocs-minify-plugin = "*"
mkdocs-rss-plugin = "*"
mkdocs-git-committers-plugin-2 = "*"
mkdocs-material = {extras = ["imaging"], version = "*"}
markdown-callouts = "*"
markdown-include = "*"
mkdocs-exclude = "*"
psycopg = {extras = ["binary"], version = "^3.2.0"}
psycopg-pool = "^3.2.0"
pygments-ansi-color = ">=0.3"
@@ -49,6 +49,7 @@ langchain-anthropic = "^0.3.8"
langchain-nomic = "^0.1.3"
langchain-fireworks = "^0.2.0"
langchain-community = "^0.3.0"
langchain-tavily = "^0.1.5"
langchain-experimental = "^0.3.2"
langchain-mistralai = "^0.2.6"
langgraph-checkpoint-mongodb = "^0.1.0"
+8 -8
View File
@@ -279,31 +279,31 @@ test = ["pytest (>=6)"]
[[package]]
name = "h11"
version = "0.14.0"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.7"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"},
{file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"},
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
+21 -25
View File
@@ -215,54 +215,51 @@ test = ["pytest (>=6)"]
[[package]]
name = "h11"
version = "0.14.0"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "httpcore"
version = "1.0.5"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "0.13.2"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.6"
groups = ["main", "dev"]
files = [
{file = "httpcore-0.13.2-py3-none-any.whl", hash = "sha256:52b7d9413f6f5592a667de9209d70d4d41aba3fb0540dd7c93475c78b85941e9"},
{file = "httpcore-0.13.2.tar.gz", hash = "sha256:c16efbdf643e1b57bde0adc12c53b08645d7d92d6d345a3f71adfc2a083e7fd2"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
h11 = "==0.*"
sniffio = "==1.*"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.27.2"
version = "0.25.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"},
{file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"},
{file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"},
{file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
httpcore = "*"
idna = "*"
sniffio = "*"
@@ -271,7 +268,6 @@ brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "idna"
@@ -93,6 +93,7 @@ class InMemorySaver(
if factory is not defaultdict:
self.stack.enter_context(self.storage) # type: ignore[arg-type]
self.stack.enter_context(self.writes) # type: ignore[arg-type]
self.stack.enter_context(self.blobs) # type: ignore[arg-type]
def __enter__(self) -> "InMemorySaver":
return self.stack.__enter__()
+8 -8
View File
@@ -212,31 +212,31 @@ test = ["pytest (>=6)"]
[[package]]
name = "h11"
version = "0.14.0"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.7"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"},
{file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"},
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint"
version = "2.0.24"
version = "2.0.25"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
license = "MIT"
+33 -15
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
[[package]]
name = "anyio"
@@ -6,6 +6,7 @@ version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
@@ -28,6 +29,7 @@ version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
@@ -39,6 +41,7 @@ version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
@@ -53,6 +56,8 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -64,6 +69,8 @@ version = "1.2.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
@@ -74,45 +81,48 @@ test = ["pytest (>=6)"]
[[package]]
name = "h11"
version = "0.14.0"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.5"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
@@ -120,13 +130,13 @@ anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "httpx-sse"
@@ -134,6 +144,7 @@ version = "0.4.0"
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
@@ -145,6 +156,7 @@ version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
groups = ["main"]
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
@@ -156,6 +168,7 @@ version = "0.1.52"
description = "CLI for interacting with LangGraph API"
optional = false
python-versions = "^3.9.0,<4.0"
groups = ["main"]
files = []
develop = true
@@ -172,6 +185,7 @@ version = "0.1.29"
description = "SDK for interacting with LangGraph API"
optional = false
python-versions = "^3.9.0,<4.0"
groups = ["main"]
files = []
develop = true
@@ -190,6 +204,7 @@ version = "3.10.5"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"},
@@ -245,6 +260,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -256,12 +272,14 @@ version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version < \"3.11\""
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[metadata]
lock-version = "2.0"
lock-version = "2.1"
python-versions = "^3.9.0,<4.0"
content-hash = "ec5109729f30d2033a10a10e8f8d3ed94c7d96d5d31025b4815b0123664bb063"
+32 -17
View File
@@ -619,20 +619,30 @@
zod "^3.22.4"
zod-to-json-schema "^3.22.3"
"@langchain/langgraph-checkpoint@~0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.6.tgz#69f0c5c9aeefd48dcf0fa1ffa0744d8139a9f27d"
integrity sha512-hQsznlUMFKyOCaN9VtqNSSemfKATujNy5ePM6NX7lruk/Mmi2t7R9SsBnf9G2Yts+IaIwv3vJJaAFYEHfqbc5g==
"@langchain/langgraph-checkpoint@~0.0.17":
version "0.0.17"
resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.17.tgz#d0a8824eb0769567da54262adebe65db4ee6d58f"
integrity sha512-6b3CuVVYx+7x0uWLG+7YXz9j2iBa+tn2AXvkLxzEvaAsLE6Sij++8PPbS2BZzC+S/FPJdWsz6I5bsrqL0BYrCA==
dependencies:
uuid "^10.0.0"
"@langchain/langgraph@^0.2.5":
version "0.2.5"
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-0.2.5.tgz#c42743a59adef03f2e1fea0c198a01694ae34d51"
integrity sha512-H4OgZyGRWZHBaiXXIb9avyB8zI6+3OewKn+UOZ+wUzYLKyF3cnq0cNF4/Ps+gxCa5RtOnsHIqQyRkojfXIOqgA==
"@langchain/langgraph-sdk@~0.0.32":
version "0.0.70"
resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.70.tgz#9589f984b47de5e4a669b6008cbf01427a3d41d0"
integrity sha512-O8I12bfeMVz5fOrXnIcK4IdRf50IqyJTO458V56wAIHLNoi4H8/JHM+2M+Y4H2PtslXIGnvomWqlBd0eY5z/Og==
dependencies:
"@langchain/langgraph-checkpoint" "~0.0.6"
double-ended-queue "^2.1.0-0"
"@types/json-schema" "^7.0.15"
p-queue "^6.6.2"
p-retry "4"
uuid "^9.0.0"
"@langchain/langgraph@^0.2.5":
version "0.2.67"
resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-0.2.67.tgz#f154e4e8534ca78b2b73a2cd3a901d82790b0d1b"
integrity sha512-tu/ewNIvhIPzeW5GxGzqjmGHinnU/qbNAoLM9czdpci0PCbMysbEJ2pbJrZs7ZjaReWSnr/THkeLPQwqGOM9xw==
dependencies:
"@langchain/langgraph-checkpoint" "~0.0.17"
"@langchain/langgraph-sdk" "~0.0.32"
uuid "^10.0.0"
zod "^3.23.8"
@@ -748,7 +758,7 @@
expect "^29.0.0"
pretty-format "^29.0.0"
"@types/json-schema@^7.0.9":
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
@@ -1422,11 +1432,6 @@ dotenv@^16.4.5:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==
double-ended-queue@^2.1.0-0:
version "2.1.0-0"
resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c"
integrity sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ==
ejs@^3.1.10:
version "3.1.10"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b"
@@ -3685,6 +3690,11 @@ uuid@^10.0.0:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
uuid@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
v8-to-istanbul@^9.0.1:
version "9.3.0"
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175"
@@ -3795,7 +3805,12 @@ zod-to-json-schema@^3.22.3:
resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz#bc7e379c8050462538383e382964c03d8fe008f9"
integrity sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==
zod@^3.22.4, zod@^3.23.8:
zod@^3.22.4:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
zod@^3.23.8:
version "3.24.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87"
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
+24
View File
@@ -168,6 +168,13 @@ def cli():
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH
@OPT_POSTGRES_URI
@click.option(
"--image",
type=str,
default=None,
help="Docker image to use for the langgraph-api service. If specified, skips building and uses this image directly."
" Useful if you want to test against an image already built using `langgraph build`.",
)
@click.option(
"--wait",
is_flag=True,
@@ -187,6 +194,7 @@ def up(
debugger_port: Optional[int],
debugger_base_url: Optional[str],
postgres_uri: Optional[str],
image: Optional[str],
):
click.secho("Starting LangGraph API server...", fg="green")
click.secho(
@@ -207,6 +215,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image,
)
# add up + options
args.extend(["up", "--remove-orphans"])
@@ -572,6 +581,14 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
help="Don't raise errors for synchronous I/O blocking operations in your code.",
default=False,
)
@click.option(
"--tunnel",
is_flag=True,
help="Expose the local server via a public tunnel (in this case, Cloudflare) "
"for remote frontend access. This avoids issues with browsers "
"or networks blocking localhost connections.",
default=False,
)
@cli.command(
"dev",
help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
@@ -588,6 +605,7 @@ def dev(
wait_for_client: bool,
studio_url: Optional[str],
allow_blocking: bool,
tunnel: bool,
):
"""CLI entrypoint for running the LangGraph API server."""
try:
@@ -655,6 +673,7 @@ def dev(
ui_config=config_json.get("ui_config"),
studio_url=studio_url,
allow_blocking=allow_blocking,
tunnel=tunnel,
)
@@ -682,6 +701,7 @@ def prepare_args_and_stdin(
debugger_port: Optional[int] = None,
debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None,
image: Optional[str] = None,
) -> Tuple[List[str], str]:
assert config_path.exists(), f"Config file not found: {config_path}"
# prepare args
@@ -691,6 +711,7 @@ def prepare_args_and_stdin(
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image, # Pass image to compose YAML generator
)
args = [
"--project-directory",
@@ -706,6 +727,7 @@ def prepare_args_and_stdin(
config,
watch=watch,
base_image=langgraph_cli.config.default_base_image(config),
image=image,
)
return args, stdin
@@ -723,6 +745,7 @@ def prepare(
debugger_port: Optional[int] = None,
debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None,
image: Optional[str] = None,
) -> Tuple[List[str], str]:
"""Prepare the arguments and stdin for running the LangGraph API server."""
config_json = langgraph_cli.config.validate_config_file(config_path)
@@ -747,5 +770,6 @@ def prepare(
debugger_port=debugger_port,
debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
postgres_uri=postgres_uri,
image=image,
)
return args, stdin
+18 -8
View File
@@ -1288,6 +1288,7 @@ def config_to_compose(
config_path: pathlib.Path,
config: Config,
base_image: Optional[str] = None,
image: Optional[str] = None,
watch: bool = False,
) -> str:
base_image = base_image or default_base_image(config)
@@ -1314,19 +1315,28 @@ def config_to_compose(
"""
else:
watch_str = ""
if image:
return f"""
{textwrap.indent(env_vars_str, " ")}
{env_file_str}
{watch_str}
"""
dockerfile, additional_contexts = config_to_docker(config_path, config, base_image)
else:
dockerfile, additional_contexts = config_to_docker(
config_path, config, base_image
)
additional_contexts_str = "\n".join(
f" - {name}: {path}"
for name, path in additional_contexts.items()
)
if additional_contexts_str:
additional_contexts_str = f"""
additional_contexts_str = "\n".join(
f" - {name}: {path}"
for name, path in additional_contexts.items()
)
if additional_contexts_str:
additional_contexts_str = f"""
additional_contexts:
{additional_contexts_str}"""
return f"""
return f"""
{textwrap.indent(env_vars_str, " ")}
{env_file_str}
pull_policy: build
+6
View File
@@ -143,6 +143,8 @@ def compose_as_dict(
debugger_base_url: Optional[str] = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: Optional[str] = None,
# If you are running against an already-built image, you can pass it here
image: Optional[str] = None,
) -> dict:
"""Create a docker compose file as a dictionary in YML style."""
if postgres_uri is None:
@@ -211,6 +213,8 @@ def compose_as_dict(
"POSTGRES_URI": postgres_uri,
},
}
if image:
services["langgraph-api"]["image"] = image
# If Postgres is included, add it to the dependencies of langgraph-api
if include_db:
@@ -244,6 +248,7 @@ def compose(
debugger_base_url: Optional[str] = None,
# postgres://user:password@host:port/database?option=value
postgres_uri: Optional[str] = None,
image: Optional[str] = None,
) -> str:
"""Create a docker compose file as a string."""
compose_content = compose_as_dict(
@@ -252,6 +257,7 @@ def compose(
debugger_port=debugger_port,
debugger_base_url=debugger_base_url,
postgres_uri=postgres_uri,
image=image,
)
compose_str = dict_to_yaml(compose_content)
return compose_str
+37 -37
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
[[package]]
name = "annotated-types"
@@ -33,7 +33,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"]
trio = ["trio (>=0.26.1)"]
[[package]]
@@ -292,7 +292,7 @@ files = [
[package.extras]
dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"]
hard-encoding-detection = ["chardet"]
toml = ["tomli ; python_version < \"3.11\""]
toml = ["tomli"]
types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"]
[[package]]
@@ -358,10 +358,10 @@ files = [
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
@@ -408,33 +408,33 @@ files = [
[[package]]
name = "h11"
version = "0.14.0"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = true
python-versions = ">=3.7"
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\""
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.8"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version >= \"3.11\""
files = [
{file = "httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be"},
{file = "httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad"},
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
@@ -462,7 +462,7 @@ httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
@@ -585,15 +585,15 @@ tests = ["flask (>=2.2.5)", "hypothesis (>=6.79.4)", "pytest (>=7.4.4)"]
[[package]]
name = "langchain-core"
version = "0.3.54"
version = "0.3.55"
description = "Building applications with LLMs through composability"
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langchain_core-0.3.54-py3-none-any.whl", hash = "sha256:cd42155d9089e2fd4695ee02a4b2bc6daf55b9d4e1a37639647cf2455ed4fa04"},
{file = "langchain_core-0.3.54.tar.gz", hash = "sha256:55ce38939038e19b1271f36f512335462d7f64057b531598b3651d2b403e1b42"},
{file = "langchain_core-0.3.55-py3-none-any.whl", hash = "sha256:b3cb36bf37755a616158a79866657c6697b43a2f7c69dd723ce425f1c76c1baa"},
{file = "langchain_core-0.3.55.tar.gz", hash = "sha256:0f2b3e311621116a83510c70b0ac9d959030a0a457a69483535cff18501fedc9"},
]
[package.dependencies]
@@ -630,15 +630,15 @@ xxhash = ">=3.5.0,<4.0.0"
[[package]]
name = "langgraph-api"
version = "0.1.9"
version = "0.1.12"
description = ""
optional = true
python-versions = "<4.0,>=3.11.0"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langgraph_api-0.1.9-py3-none-any.whl", hash = "sha256:f84b11b1855e68dbef9f0a78db803e325b8dc11e2e19613178ab84cb0d99627c"},
{file = "langgraph_api-0.1.9.tar.gz", hash = "sha256:3530d82e715b9f99eeb8753c365f4d16c99ce60533fa15530d5ad1d493aeec06"},
{file = "langgraph_api-0.1.12-py3-none-any.whl", hash = "sha256:0f9417052ac75f6da892902083b7cf6a515bee12dd035cfbc3f0ddb813738830"},
{file = "langgraph_api-0.1.12.tar.gz", hash = "sha256:1646a904121a5dc84cece6a81b9c49693ccfbd6f1a2904e0ed7aa7eb711e64fc"},
]
[package.dependencies]
@@ -712,15 +712,15 @@ blockbuster = ">=1.5.24,<2.0.0"
[[package]]
name = "langgraph-sdk"
version = "0.1.61"
version = "0.1.63"
description = "SDK for interacting with LangGraph API"
optional = true
python-versions = "<4.0.0,>=3.9.0"
groups = ["main"]
markers = "python_version >= \"3.11\""
files = [
{file = "langgraph_sdk-0.1.61-py3-none-any.whl", hash = "sha256:f2d774b12497c428862993090622d51e0dbc3f53e0cee3d74a13c7495d835cc6"},
{file = "langgraph_sdk-0.1.61.tar.gz", hash = "sha256:87dd1f07ab82da8875ac343268ece8bf5414632017ebc9d1cef4b523962fd601"},
{file = "langgraph_sdk-0.1.63-py3-none-any.whl", hash = "sha256:6fb78a7fc6a30eea43bd0d6401dbc9e3263d0d4c03f63c04035980da7e586b05"},
{file = "langgraph_sdk-0.1.63.tar.gz", hash = "sha256:62bf2cc31e5aa6c5b9011ee1702bcf1e36e67e142a60bd97af2611162fb58e18"},
]
[package.dependencies]
@@ -729,15 +729,15 @@ orjson = ">=3.10.1"
[[package]]
name = "langsmith"
version = "0.3.32"
version = "0.3.33"
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
optional = true
python-versions = "<4.0,>=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "langsmith-0.3.32-py3-none-any.whl", hash = "sha256:d79af299038cd13db6d53f99fdc6a7171b731702536b7635f2da132555a6bebc"},
{file = "langsmith-0.3.32.tar.gz", hash = "sha256:3d7b1149e9fbe0f388303bc94d8deeeb822acc89cdea34f24151ea1316eb487d"},
{file = "langsmith-0.3.33-py3-none-any.whl", hash = "sha256:6fa453942014945e1de7e283880ed3e8031b5d84e0dc75b87d101ecedb62371b"},
{file = "langsmith-0.3.33.tar.gz", hash = "sha256:0f439e945528c6d14140137b918cc048aea04c6a987525926dbfda2560002924"},
]
[package.dependencies]
@@ -805,10 +805,10 @@ files = [
]
[package.extras]
dev = ["attrs", "coverage", "eval-type-backport ; python_version < \"3.10\"", "furo", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli ; python_version < \"3.11\"", "tomli_w"]
dev = ["attrs", "coverage", "eval-type-backport", "furo", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli", "tomli_w"]
doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"]
test = ["attrs", "eval-type-backport ; python_version < \"3.10\"", "msgpack", "pytest", "pyyaml", "tomli ; python_version < \"3.11\"", "tomli_w"]
toml = ["tomli ; python_version < \"3.11\"", "tomli_w"]
test = ["attrs", "eval-type-backport", "msgpack", "pytest", "pyyaml", "tomli", "tomli_w"]
toml = ["tomli", "tomli_w"]
yaml = ["pyyaml"]
[[package]]
@@ -1071,7 +1071,7 @@ typing-inspection = ">=0.4.0"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
timezone = ["tzdata"]
[[package]]
name = "pydantic-core"
@@ -1604,22 +1604,22 @@ files = [
]
[package.extras]
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.34.1"
version = "0.34.2"
description = "The lightning-fast ASGI server."
optional = true
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version >= \"3.11\" and extra == \"inmem\""
files = [
{file = "uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065"},
{file = "uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc"},
{file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"},
{file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"},
]
[package.dependencies]
@@ -1627,7 +1627,7 @@ click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "watchdog"
@@ -2011,4 +2011,4 @@ inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
[metadata]
lock-version = "2.1"
python-versions = "^3.9.0,<4.0"
content-hash = "afc2f8776b4b6144bd1197df49ba34089889e2a1110b8470d8f1b212e0b08380"
content-hash = "6f3f275ae70749922db5bd1105711fbb0f8ac3b215982f7c75861c93d956e95d"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.2.5"
version = "0.2.7"
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.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-api = { version = ">=0.1.12,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-runtime-inmem = { version = ">=0.0.1,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-sdk = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
python-dotenv = { version = ">=0.8.0", optional = true }
+104
View File
@@ -160,6 +160,110 @@ services:
assert clean_empty_lines(actual_stdin) == expected_stdin
def test_prepare_args_and_stdin_with_image() -> None:
# this basically serves as an end-to-end test for using config and docker helpers
config_path = pathlib.Path(__file__).parent / "langgraph.json"
config = validate_config(
Config(dependencies=[".", "../../.."], graphs={"agent": "agent.py:graph"})
)
port = 8000
debugger_port = 8001
debugger_graph_url = f"http://127.0.0.1:{port}"
actual_args, actual_stdin = prepare_args_and_stdin(
capabilities=DEFAULT_DOCKER_CAPABILITIES,
config_path=config_path,
config=config,
docker_compose=pathlib.Path("custom-docker-compose.yml"),
port=port,
debugger_port=debugger_port,
debugger_base_url=debugger_graph_url,
watch=True,
image="my-cool-image",
)
expected_args = [
"--project-directory",
str(pathlib.Path(__file__).parent.absolute()),
"-f",
"custom-docker-compose.yml",
"-f",
"-",
]
expected_stdin = f"""volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 60s
start_interval: 1s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "{debugger_port}:3968"
environment:
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
langgraph-api:
ports:
- "8000:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
image: my-cool-image
healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s
develop:
watch:
- path: langgraph.json
action: rebuild
- path: .
action: rebuild
- path: ../../..
action: rebuild\
"""
assert actual_args == expected_args
assert clean_empty_lines(actual_stdin) == expected_stdin
def test_version_option() -> None:
"""Test the --version option of the CLI."""
runner = CliRunner()
+1 -1
View File
@@ -78,7 +78,7 @@ test_watch_all:
PYTHON_FILES=.
MYPY_CACHE=.mypy_cache
lint format: PYTHON_FILES=.
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E r'\.py$$|\.ipynb$$')
lint_package: PYTHON_FILES=langgraph
lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
+2 -1
View File
@@ -1,7 +1,8 @@
import operator
from collections.abc import Sequence
from functools import partial
from random import choice
from typing import Annotated, Optional, Sequence
from typing import Annotated, Optional
from pydantic import BaseModel, Field, field_validator
+2 -1
View File
@@ -1,7 +1,8 @@
import operator
from collections.abc import Sequence
from functools import partial
from random import choice
from typing import Annotated, Optional, Sequence
from typing import Annotated, Optional
from typing_extensions import TypedDict
+2 -1
View File
@@ -1,8 +1,9 @@
import operator
from collections.abc import Sequence
from dataclasses import dataclass, field
from functools import partial
from random import choice
from typing import Annotated, Optional, Sequence
from typing import Annotated, Optional
from langgraph.constants import END, START
from langgraph.graph.state import StateGraph
+2 -2
View File
@@ -1,6 +1,6 @@
import functools
import warnings
from typing import Any, Callable, Type, TypeVar, Union, cast
from typing import Any, Callable, TypeVar, Union, cast
class LangGraphDeprecationWarning(DeprecationWarning):
@@ -8,7 +8,7 @@ class LangGraphDeprecationWarning(DeprecationWarning):
F = TypeVar("F", bound=Callable[..., Any])
C = TypeVar("C", bound=Type[Any])
C = TypeVar("C", bound=type[Any])
def deprecated(
@@ -1,4 +1,5 @@
from typing import Any, Generic, Sequence, Type
from collections.abc import Sequence
from typing import Any, Generic
from typing_extensions import Self
@@ -21,12 +22,12 @@ class AnyValue(Generic[Value], BaseChannel[Value, Value, Value]):
return isinstance(value, AnyValue)
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
+2 -1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, Generic, Sequence, TypeVar
from collections.abc import Sequence
from typing import Any, Generic, TypeVar
from typing_extensions import Self
+5 -4
View File
@@ -1,5 +1,6 @@
import collections.abc
from typing import Callable, Generic, Sequence, Type
from collections.abc import Sequence
from typing import Callable, Generic
from typing_extensions import NotRequired, Required, Self
@@ -31,7 +32,7 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
__slots__ = ("value", "operator")
def __init__(self, typ: Type[Value], operator: Callable[[Value, Value], Value]):
def __init__(self, typ: type[Value], operator: Callable[[Value, Value], Value]):
super().__init__(typ)
self.operator = operator
# special forms from typing or collections.abc are not instantiable
@@ -57,12 +58,12 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
)
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
@@ -1,4 +1,5 @@
from typing import Any, Generic, NamedTuple, Optional, Sequence, Type, Union
from collections.abc import Sequence, Set
from typing import Any, Generic, NamedTuple, Optional, Union
from typing_extensions import Self
@@ -8,11 +9,11 @@ from langgraph.errors import EmptyChannelError, InvalidUpdateError
class WaitForNames(NamedTuple):
names: set[Any]
names: Set[Any]
class DynamicBarrierValue(
Generic[Value], BaseChannel[Value, Union[Value, WaitForNames], set[Value]]
Generic[Value], BaseChannel[Value, Union[Value, WaitForNames], Set[Value]]
):
"""A channel that switches between two states
@@ -25,10 +26,10 @@ class DynamicBarrierValue(
__slots__ = ("names", "seen")
names: Optional[set[Value]]
names: Optional[Set[Value]]
seen: set[Value]
def __init__(self, typ: Type[Value]) -> None:
def __init__(self, typ: type[Value]) -> None:
super().__init__(typ)
self.names = None
self.seen = set()
@@ -37,12 +38,12 @@ class DynamicBarrierValue(
return isinstance(value, DynamicBarrierValue) and value.names == self.names
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
@@ -54,11 +55,11 @@ class DynamicBarrierValue(
empty.seen = self.seen.copy()
return empty
def checkpoint(self) -> tuple[Optional[set[Value]], set[Value]]:
def checkpoint(self) -> tuple[Optional[Set[Value]], set[Value]]:
return (self.names, self.seen)
def from_checkpoint(
self, checkpoint: tuple[Optional[set[Value]], set[Value]]
self, checkpoint: tuple[Optional[Set[Value]], set[Value]]
) -> Self:
empty = self.__class__(self.typ)
empty.key = self.key
@@ -1,4 +1,5 @@
from typing import Any, Generic, Sequence, Type
from collections.abc import Sequence
from typing import Any, Generic
from typing_extensions import Self
@@ -21,12 +22,12 @@ class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
return isinstance(value, EphemeralValue) and value.guard == self.guard
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
@@ -1,4 +1,5 @@
from typing import Any, Generic, Sequence, Type
from collections.abc import Sequence
from typing import Any, Generic
from typing_extensions import Self
@@ -25,12 +26,12 @@ class LastValue(Generic[Value], BaseChannel[Value, Value, Value]):
return isinstance(value, LastValue)
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
@@ -1,4 +1,5 @@
from typing import Generic, Sequence, Type
from collections.abc import Sequence
from typing import Generic
from typing_extensions import Self
@@ -15,7 +16,7 @@ class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]):
names: set[Value]
seen: set[Value]
def __init__(self, typ: Type[Value], names: set[Value]) -> None:
def __init__(self, typ: type[Value], names: set[Value]) -> None:
super().__init__(typ)
self.names = names
self.seen: set[str] = set()
@@ -24,12 +25,12 @@ class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]):
return isinstance(value, NamedBarrierValue) and value.names == self.names
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
+3 -2
View File
@@ -1,4 +1,5 @@
from typing import Any, Generic, Iterator, Sequence, Type, Union
from collections.abc import Iterator, Sequence
from typing import Any, Generic, Union
from typing_extensions import Self
@@ -28,7 +29,7 @@ class Topic(
__slots__ = ("values", "accumulate")
def __init__(self, typ: Type[Value], accumulate: bool = False) -> None:
def __init__(self, typ: type[Value], accumulate: bool = False) -> None:
super().__init__(typ)
# attrs
self.accumulate = accumulate
@@ -1,4 +1,5 @@
from typing import Generic, Sequence, Type
from collections.abc import Sequence
from typing import Generic
from typing_extensions import Self
@@ -12,7 +13,7 @@ class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]):
__slots__ = ("value", "guard")
def __init__(self, typ: Type[Value], guard: bool = True) -> None:
def __init__(self, typ: type[Value], guard: bool = True) -> None:
super().__init__(typ)
self.guard = guard
self.value = MISSING
@@ -21,12 +22,12 @@ class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]):
return isinstance(value, UntrackedValue) and value.guard == self.guard
@property
def ValueType(self) -> Type[Value]:
def ValueType(self) -> type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
def UpdateType(self) -> type[Value]:
"""The type of the update received by the channel."""
return self.typ
+2 -1
View File
@@ -1,6 +1,7 @@
import sys
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Literal, Mapping, cast
from typing import Any, Literal, cast
from langgraph.types import Interrupt, Send # noqa: F401
+2 -1
View File
@@ -1,5 +1,6 @@
from collections.abc import Sequence
from enum import Enum
from typing import Any, Sequence
from typing import Any
from langgraph.checkpoint.base import EmptyChannelError # noqa: F401
from langgraph.types import Command, Interrupt
+1 -2
View File
@@ -2,14 +2,13 @@ import asyncio
import concurrent.futures
import functools
import inspect
from collections.abc import Awaitable, Sequence
from dataclasses import dataclass
from typing import (
Any,
Awaitable,
Callable,
Generic,
Optional,
Sequence,
TypeVar,
Union,
get_args,
+37 -20
View File
@@ -1,19 +1,17 @@
from collections.abc import Awaitable, Hashable, Sequence
from inspect import (
isfunction,
ismethod,
signature,
)
from itertools import zip_longest
from types import FunctionType
from typing import (
Any,
Awaitable,
Callable,
Hashable,
Literal,
NamedTuple,
Optional,
Sequence,
Type,
Union,
cast,
get_args,
@@ -29,12 +27,17 @@ from langchain_core.runnables import (
from langgraph.constants import END, START
from langgraph.errors import InvalidUpdateError
from langgraph.pregel.write import ChannelWrite
from langgraph.pregel.write import PASSTHROUGH, ChannelWrite, ChannelWriteEntry
from langgraph.types import Send
from langgraph.utils.runnable import (
RunnableCallable,
)
Writer = Callable[
[Sequence[Union[str, Send]], bool],
Sequence[Union[ChannelWriteEntry, Send]],
]
def _get_branch_path_input_schema(
path: Union[
@@ -42,7 +45,7 @@ def _get_branch_path_input_schema(
Callable[..., Awaitable[Union[Hashable, list[Hashable]]]],
Runnable[Any, Union[Hashable, list[Hashable]]],
],
) -> Optional[Type[Any]]:
) -> Optional[type[Any]]:
input = None
# detect input schema annotation in the branch callable
try:
@@ -85,7 +88,7 @@ class Branch(NamedTuple):
path: Runnable[Any, Union[Hashable, list[Hashable]]]
ends: Optional[dict[Hashable, str]]
then: Optional[str] = None
input_schema: Optional[Type[Any]] = None
input_schema: Optional[type[Any]] = None
@classmethod
def from_path(
@@ -124,9 +127,7 @@ class Branch(NamedTuple):
def run(
self,
writer: Callable[
[Sequence[Union[str, Send]], RunnableConfig], Optional[ChannelWrite]
],
writer: Writer,
reader: Optional[Callable[[RunnableConfig], Any]] = None,
) -> RunnableCallable:
return ChannelWrite.register_writer(
@@ -138,7 +139,15 @@ class Branch(NamedTuple):
name=None,
trace=False,
func_accepts_config=True,
),
list(
zip_longest(
writer([e for e in self.ends.values()], True),
[str(la) for la, e in self.ends.items()],
)
)
if self.ends
else None,
)
def _route(
@@ -147,9 +156,7 @@ class Branch(NamedTuple):
config: RunnableConfig,
*,
reader: Optional[Callable[[RunnableConfig], Any]],
writer: Callable[
[Sequence[Union[str, Send]], RunnableConfig], Optional[ChannelWrite]
],
writer: Writer,
) -> Runnable:
if reader:
value = reader(config)
@@ -172,9 +179,7 @@ class Branch(NamedTuple):
config: RunnableConfig,
*,
reader: Optional[Callable[[RunnableConfig], Any]],
writer: Callable[
[Sequence[Union[str, Send]], RunnableConfig], Optional[ChannelWrite]
],
writer: Writer,
) -> Runnable:
if reader:
value = reader(config)
@@ -193,9 +198,7 @@ class Branch(NamedTuple):
def _finish(
self,
writer: Callable[
[Sequence[Union[str, Send]], RunnableConfig], Optional[ChannelWrite]
],
writer: Writer,
input: Any,
result: Any,
config: RunnableConfig,
@@ -212,4 +215,18 @@ class Branch(NamedTuple):
raise ValueError("Branch did not return a valid destination")
if any(p.node == END for p in destinations if isinstance(p, Send)):
raise InvalidUpdateError("Cannot send a packet to the END node")
return writer(destinations, config) or input
entries = writer(destinations, False)
if not entries:
return input
else:
need_passthrough = False
for e in entries:
if isinstance(e, ChannelWriteEntry):
if e.value is PASSTHROUGH:
need_passthrough = True
break
if need_passthrough:
return ChannelWrite(entries)
else:
ChannelWrite.do_write(config, entries)
return input
+7 -183
View File
@@ -1,23 +1,17 @@
import asyncio
import logging
from collections import defaultdict
from collections.abc import Awaitable, Hashable, Sequence
from typing import (
Any,
Awaitable,
Callable,
Hashable,
NamedTuple,
Optional,
Sequence,
Union,
cast,
overload,
)
from langchain_core.runnables import Runnable
from langchain_core.runnables.config import RunnableConfig
from langchain_core.runnables.graph import Graph as DrawableGraph
from langchain_core.runnables.graph import Node as DrawableNode
from typing_extensions import Self
from langgraph.channels.ephemeral_value import EphemeralValue
@@ -32,7 +26,6 @@ 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
@@ -182,7 +175,7 @@ class Graph:
# validate the condition
if name in self.branches[source]:
raise ValueError(
f"Branch with name `{path.name}` already exists for node " f"`{source}`"
f"Branch with name `{path.name}` already exists for node `{source}`"
)
# save it
self.branches[source][name] = Branch.from_path(path, path_map, then, False)
@@ -380,10 +373,10 @@ class CompiledGraph(Pregel):
cast(list[str], self.nodes[end].channels).append(start)
def attach_branch(self, start: str, name: str, branch: Branch) -> None:
def branch_writer(
packets: Sequence[Union[str, Send]], config: RunnableConfig
) -> Optional[ChannelWrite]:
writes = [
def get_writes(
packets: Sequence[Union[str, Send]], static: bool = False
) -> Sequence[Union[ChannelWriteEntry, Send]]:
return [
(
ChannelWriteEntry(f"branch:{start}:{name}:{p}" if p != END else END)
if not isinstance(p, Send)
@@ -391,14 +384,13 @@ class CompiledGraph(Pregel):
)
for p in packets
]
return ChannelWrite(cast(Sequence[Union[ChannelWriteEntry, Send]], writes))
# add hidden start node
if start == START and start not in self.nodes:
self.nodes[start] = Channel.subscribe_to(START, tags=[TAG_HIDDEN])
# attach branch writer
self.nodes[start] |= branch.run(branch_writer)
self.nodes[start] |= branch.run(get_writes)
# attach branch readers
ends = branch.ends.values() if branch.ends else [node for node in self.nodes]
@@ -408,171 +400,3 @@ class CompiledGraph(Pregel):
self.channels[channel_name] = EphemeralValue(Any)
self.nodes[end].triggers.append(channel_name)
cast(list[str], self.nodes[end].channels).append(channel_name)
async def aget_graph(
self,
config: Optional[RunnableConfig] = None,
*,
xray: Union[int, bool] = False,
) -> DrawableGraph:
"""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,
config: Optional[RunnableConfig] = None,
*,
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] = {}
def add_edge(
start: str,
end: str,
label: Optional[Hashable] = None,
conditional: bool = False,
) -> None:
if end == END and END not in end_nodes:
end_nodes[END] = graph.add_node(self.get_output_schema(config), END)
if start not in start_nodes or end not in end_nodes:
logger.warning(
f"Could not add edge from '{start}' to '{end}' due to missing nodes"
)
return
return graph.add_edge(
start_nodes[start],
end_nodes[end],
str(label) if label is not None else None,
conditional,
)
for key, n in self.builder.nodes.items():
node = n.runnable
metadata = n.metadata or {}
if key in self.interrupt_before_nodes and key in self.interrupt_after_nodes:
metadata["__interrupt"] = "before,after"
elif key in self.interrupt_before_nodes:
metadata["__interrupt"] = "before"
elif key in self.interrupt_after_nodes:
metadata["__interrupt"] = "after"
if key in subgraphs:
subgraph = subgraphs[key]
subgraph.trim_first_node()
subgraph.trim_last_node()
if len(subgraph.nodes) >= 1:
e, s = graph.extend(subgraph, prefix=key)
if e is None:
logger.warning(
f"Could not extend subgraph '{key}' due to missing entrypoint"
)
continue
if s is not None:
start_nodes[key] = s
end_nodes[key] = e
else:
nn = graph.add_node(node, key, metadata=metadata or None)
start_nodes[key] = nn
end_nodes[key] = nn
else:
nn = graph.add_node(node, key, metadata=metadata or None)
start_nodes[key] = nn
end_nodes[key] = nn
for start, end in sorted(self.builder._all_edges):
add_edge(start, end)
for start, branches in self.builder.branches.items():
default_ends = {
**{k: k for k in self.builder.nodes if k != start},
END: END,
}
for _, branch in branches.items():
if branch.ends is not None:
ends = branch.ends
elif branch.then is not None:
ends = {k: k for k in default_ends if k not in (END, branch.then)}
else:
ends = cast(dict[Hashable, str], default_ends)
for label, end in ends.items():
add_edge(
start,
end,
label if label != end else None,
conditional=True,
)
if branch.then is not None:
add_edge(end, branch.then)
for key, n in self.builder.nodes.items():
if isinstance(n.ends, dict):
for end, label in n.ends.items():
add_edge(key, end, label, conditional=True)
elif isinstance(n.ends, tuple):
for end in n.ends:
add_edge(key, end, conditional=True)
return graph
def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
"""Mime bundle used by Jupyter to display the graph"""
return {
"text/plain": repr(self),
"image/png": self.get_graph().draw_mermaid_png(),
}
+1 -1
View File
@@ -1,5 +1,6 @@
import uuid
import warnings
from collections.abc import Sequence
from functools import partial
from typing import (
Annotated,
@@ -7,7 +8,6 @@ from typing import (
Callable,
Literal,
Optional,
Sequence,
Union,
cast,
)
@@ -3,10 +3,10 @@ import logging
import weakref
from inspect import isclass
from typing import (
Annotated,
Any,
Callable,
Optional,
Type,
Union,
get_args,
get_origin,
@@ -15,14 +15,13 @@ from typing import (
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated
__all__ = ["SchemaCoercionMapper"]
logger = logging.getLogger(__name__)
_cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]] = (
_cache: weakref.WeakKeyDictionary[type[Any], dict[int, "SchemaCoercionMapper"]] = (
weakref.WeakKeyDictionary()
)
@@ -32,7 +31,7 @@ class SchemaCoercionMapper:
def __new__(
cls,
schema: Type[Any],
schema: type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
@@ -46,7 +45,7 @@ class SchemaCoercionMapper:
def __init__(
self,
schema: Type[Any],
schema: type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
@@ -187,7 +186,7 @@ class SchemaCoercionMapper:
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError("Expected dict, got %s" % type(v))
raise TypeError(f"Expected dict, got {type(v)}")
return v
return dict_coercer
@@ -197,7 +196,7 @@ class SchemaCoercionMapper:
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError("Expected dict, got %s" % type(v))
raise TypeError(f"Expected dict, got {type(v)}")
return v
return {k_sub(k, d - 1): v_sub(val, d - 1) for k, val in v.items()}
+64 -51
View File
@@ -3,19 +3,16 @@ import logging
import typing
import warnings
from collections import defaultdict
from collections.abc import Awaitable, Hashable, Sequence
from functools import partial
from inspect import isclass, isfunction, ismethod, signature
from types import FunctionType
from typing import (
Any,
Awaitable,
Callable,
Hashable,
Literal,
NamedTuple,
Optional,
Sequence,
Type,
Union,
cast,
get_args,
@@ -84,7 +81,7 @@ from langgraph.utils.runnable import RunnableLike, coerce_to_runnable
logger = logging.getLogger(__name__)
def _warn_invalid_state_schema(schema: Union[Type[Any], Any]) -> None:
def _warn_invalid_state_schema(schema: Union[type[Any], Any]) -> None:
if isinstance(schema, type):
return
if typing.get_args(schema):
@@ -108,7 +105,7 @@ def _get_node_name(node: RunnableLike) -> str:
class StateNodeSpec(NamedTuple):
runnable: Runnable
metadata: Optional[dict[str, Any]]
input: Type[Any]
input: type[Any]
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]]
ends: Optional[Union[tuple[str, ...], dict[str, str]]] = EMPTY_SEQ
@@ -166,15 +163,15 @@ class StateGraph(Graph):
nodes: dict[str, StateNodeSpec] # type: ignore[assignment]
channels: dict[str, BaseChannel]
managed: dict[str, ManagedValueSpec]
schemas: dict[Type[Any], dict[str, Union[BaseChannel, ManagedValueSpec]]]
schemas: dict[type[Any], dict[str, Union[BaseChannel, ManagedValueSpec]]]
def __init__(
self,
state_schema: Optional[Type[Any]] = None,
config_schema: Optional[Type[Any]] = None,
state_schema: Optional[type[Any]] = None,
config_schema: Optional[type[Any]] = None,
*,
input: Optional[Type[Any]] = None,
output: Optional[Type[Any]] = None,
input: Optional[type[Any]] = None,
output: Optional[type[Any]] = None,
) -> None:
super().__init__()
if state_schema is None:
@@ -195,7 +192,7 @@ class StateGraph(Graph):
self.schemas = {}
self.channels = {}
self.managed = {}
self.type_hints: dict[Type[Any], dict[str, Any]] = {}
self.type_hints: dict[type[Any], dict[str, Any]] = {}
self.schema = state_schema
self.input = input
self.output = output
@@ -211,7 +208,7 @@ class StateGraph(Graph):
(start, end) for starts, end in self.waiting_edges for start in starts
}
def _add_schema(self, schema: Type[Any], /, allow_managed: bool = True) -> None:
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, type_hints = _get_channels(schema)
@@ -250,7 +247,7 @@ class StateGraph(Graph):
node: RunnableLike,
*,
metadata: Optional[dict[str, Any]] = None,
input: Optional[Type[Any]] = None,
input: Optional[type[Any]] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
) -> Self:
@@ -275,7 +272,7 @@ class StateGraph(Graph):
action: RunnableLike,
*,
metadata: Optional[dict[str, Any]] = None,
input: Optional[Type[Any]] = None,
input: Optional[type[Any]] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
) -> Self:
@@ -299,7 +296,7 @@ class StateGraph(Graph):
action: Optional[RunnableLike] = None,
*,
metadata: Optional[dict[str, Any]] = None,
input: Optional[Type[Any]] = None,
input: Optional[type[Any]] = None,
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
) -> Self:
@@ -527,7 +524,7 @@ class StateGraph(Graph):
# validate the condition
if name in self.branches[source]:
raise ValueError(
f"Branch with name `{path.name}` already exists for node " f"`{source}`"
f"Branch with name `{path.name}` already exists for node `{source}`"
)
# save it
self.branches[source][name] = Branch.from_path(path, path_map, then, True)
@@ -686,12 +683,12 @@ class StateGraph(Graph):
class CompiledStateGraph(CompiledGraph):
builder: StateGraph
schema_to_mapper: dict[Type[Any], Optional[Callable[[Any], Any]]]
schema_to_mapper: dict[type[Any], Optional[Callable[[Any], Any]]]
def __init__(
self,
*,
schema_to_mapper: dict[Type[Any], Optional[Callable[[Any], Any]]],
schema_to_mapper: dict[type[Any], Optional[Callable[[Any], Any]]],
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@@ -774,7 +771,12 @@ class CompiledStateGraph(CompiledGraph):
ChannelWriteTupleEntry(
mapper=_get_root if output_keys == ["__root__"] else _get_updates
),
ChannelWriteTupleEntry(mapper=_control_branch),
ChannelWriteTupleEntry(
mapper=_control_branch,
static=_control_static(node.ends)
if node is not None and node.ends is not None
else None,
),
)
# add node and output channel
@@ -840,30 +842,32 @@ class CompiledStateGraph(CompiledGraph):
def attach_branch(
self, start: str, name: str, branch: Branch, *, with_reader: bool = True
) -> None:
def branch_writer(
packets: Sequence[Union[str, Send]], config: RunnableConfig
) -> None:
if filtered := [p for p in packets if p != END]:
writes = [
(
ChannelWriteEntry(CHANNEL_BRANCH_TO.format(p), None)
if not isinstance(p, Send)
else p
)
for p in filtered
]
if branch.then and branch.then != END:
writes.append(
ChannelWriteEntry(
f"branch:{start}:{name}::then",
WaitForNames(
{p.node if isinstance(p, Send) else p for p in filtered}
),
)
)
ChannelWrite.do_write(
config, cast(Sequence[Union[Send, ChannelWriteEntry]], writes)
def get_writes(
packets: Sequence[Union[str, Send]], static: bool = False
) -> Sequence[Union[ChannelWriteEntry, Send]]:
writes = [
(
ChannelWriteEntry(CHANNEL_BRANCH_TO.format(p), None)
if not isinstance(p, Send)
else p
)
for p in packets
if (True if static else p != END)
]
if not writes:
return []
if branch.then and branch.then != END:
writes.append(
ChannelWriteEntry(
f"branch:{start}:{name}::then",
WaitForNames(
frozenset(
p.node if isinstance(p, Send) else p for p in packets
)
),
)
)
return writes
if with_reader:
# get schema
@@ -891,7 +895,7 @@ class CompiledStateGraph(CompiledGraph):
reader = None
# attach branch publisher
self.nodes[start].writers.append(branch.run(branch_writer, reader))
self.nodes[start].writers.append(branch.run(get_writes, reader))
# attach then subscriber
if branch.then and branch.then != END:
@@ -1015,7 +1019,7 @@ class CompiledStateGraph(CompiledGraph):
def _pick_mapper(
state_keys: Sequence[str], schema: Type[Any], type_hints: Optional[dict[str, Any]]
state_keys: Sequence[str], schema: type[Any], type_hints: Optional[dict[str, Any]]
) -> Optional[Callable[[Any], Any]]:
if state_keys == ["__root__"]:
return None
@@ -1027,7 +1031,7 @@ def _pick_mapper(
return partial(_coerce_state, schema)
def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
def _coerce_state(schema: type[Any], input: dict[str, Any]) -> dict[str, Any]:
return schema(**input)
@@ -1059,6 +1063,15 @@ def _control_branch(value: Any) -> Sequence[tuple[str, Any]]:
return rtn
def _control_static(
ends: Union[tuple[str, ...], dict[str, str]],
) -> Sequence[tuple[str, Any, Optional[str]]]:
if isinstance(ends, dict):
return [(CHANNEL_BRANCH_TO.format(k), None, label) for k, label in ends.items()]
else:
return [(CHANNEL_BRANCH_TO.format(e), None, None) for e in ends]
def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
if isinstance(input, Command):
if input.graph == Command.PARENT:
@@ -1083,7 +1096,7 @@ def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
def _get_channels(
schema: Type[dict],
schema: type[dict],
) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec], dict[str, Any]]:
if not hasattr(schema, "__annotations__"):
return (
@@ -1137,7 +1150,7 @@ def _get_channel(
return fallback
def _is_field_channel(typ: Type[Any]) -> Optional[BaseChannel]:
def _is_field_channel(typ: type[Any]) -> Optional[BaseChannel]:
if hasattr(typ, "__metadata__"):
meta = typ.__metadata__
if len(meta) >= 1 and isinstance(meta[-1], BaseChannel):
@@ -1147,7 +1160,7 @@ def _is_field_channel(typ: Type[Any]) -> Optional[BaseChannel]:
return None
def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
def _is_field_binop(typ: type[Any]) -> Optional[BinaryOperatorAggregate]:
if hasattr(typ, "__metadata__"):
meta = typ.__metadata__
if len(meta) >= 1 and callable(meta[-1]):
@@ -1168,7 +1181,7 @@ def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
return None
def _is_field_managed_value(name: str, typ: Type[Any]) -> Optional[ManagedValueSpec]:
def _is_field_managed_value(name: str, typ: type[Any]) -> Optional[ManagedValueSpec]:
if hasattr(typ, "__metadata__"):
meta = typ.__metadata__
if len(meta) >= 1:
@@ -1186,7 +1199,7 @@ def _is_field_managed_value(name: str, typ: Type[Any]) -> Optional[ManagedValueS
def _get_schema(
typ: Type,
typ: type,
schemas: dict,
channels: dict,
name: str,
+5 -8
View File
@@ -1,14 +1,11 @@
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager
from inspect import isclass
from typing import (
Any,
AsyncIterator,
Generic,
Iterator,
NamedTuple,
Sequence,
Type,
TypeVar,
Union,
)
@@ -66,11 +63,11 @@ class WritableManagedValue(Generic[V, U], ManagedValue[V], ABC):
class ConfiguredManagedValue(NamedTuple):
cls: Type[ManagedValue]
cls: type[ManagedValue]
kwargs: dict[str, Any]
ManagedValueSpec = Union[Type[ManagedValue], ConfiguredManagedValue]
ManagedValueSpec = Union[type[ManagedValue], ConfiguredManagedValue]
def is_managed_value(value: Any) -> TypeGuard[ManagedValueSpec]:
@@ -79,7 +76,7 @@ def is_managed_value(value: Any) -> TypeGuard[ManagedValueSpec]:
)
def is_readonly_managed_value(value: Any) -> TypeGuard[Type[ManagedValue]]:
def is_readonly_managed_value(value: Any) -> TypeGuard[type[ManagedValue]]:
return (
isclass(value)
and issubclass(value, ManagedValue)
@@ -90,7 +87,7 @@ def is_readonly_managed_value(value: Any) -> TypeGuard[Type[ManagedValue]]:
)
def is_writable_managed_value(value: Any) -> TypeGuard[Type[WritableManagedValue]]:
def is_writable_managed_value(value: Any) -> TypeGuard[type[WritableManagedValue]]:
return (isclass(value) and issubclass(value, WritableManagedValue)) or (
isinstance(value, ConfiguredManagedValue)
and issubclass(value.cls, WritableManagedValue)
+17 -14
View File
@@ -1,15 +1,16 @@
from contextlib import asynccontextmanager, contextmanager
from collections.abc import AsyncIterator, Iterator
from contextlib import (
AbstractAsyncContextManager,
AbstractContextManager,
asynccontextmanager,
contextmanager,
)
from inspect import signature
from typing import (
Any,
AsyncContextManager,
AsyncIterator,
Callable,
ContextManager,
Generic,
Iterator,
Optional,
Type,
Union,
)
@@ -28,15 +29,15 @@ class Context(ManagedValue[V], Generic[V]):
def of(
ctx: Union[
None,
Callable[..., ContextManager[V]],
Type[ContextManager[V]],
Callable[..., AsyncContextManager[V]],
Type[AsyncContextManager[V]],
Callable[..., AbstractContextManager[V]],
type[AbstractContextManager[V]],
Callable[..., AbstractAsyncContextManager[V]],
type[AbstractAsyncContextManager[V]],
] = None,
actx: Optional[
Union[
Callable[..., AsyncContextManager[V]],
Type[AsyncContextManager[V]],
Callable[..., AbstractAsyncContextManager[V]],
type[AbstractAsyncContextManager[V]],
]
] = None,
) -> ConfiguredManagedValue:
@@ -98,8 +99,10 @@ class Context(ManagedValue[V], Generic[V]):
self,
loop: LoopProtocol,
*,
ctx: Union[None, Type[ContextManager[V]], Type[AsyncContextManager[V]]] = None,
actx: Optional[Type[AsyncContextManager[V]]] = None,
ctx: Union[
None, type[AbstractContextManager[V]], type[AbstractAsyncContextManager[V]]
] = None,
actx: Optional[type[AbstractAsyncContextManager[V]]] = None,
) -> None:
self.ctx = ctx
self.actx = actx
@@ -1,12 +1,9 @@
import collections.abc
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager
from typing import (
Any,
AsyncIterator,
Iterator,
Optional,
Sequence,
Type,
)
from typing_extensions import NotRequired, Required, Self
@@ -71,7 +68,7 @@ class SharedValue(WritableManagedValue[Value, Update]):
yield value
def __init__(
self, loop: LoopProtocol, *, typ: Type[Any], scope: str, key: str
self, loop: LoopProtocol, *, typ: type[Any], scope: str, key: str
) -> None:
super().__init__(loop)
if typ := _strip_extras(typ):
+233 -148
View File
@@ -6,17 +6,11 @@ import concurrent.futures
import queue
import weakref
from collections import defaultdict, deque
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from functools import partial
from typing import (
Any,
AsyncIterator,
Callable,
Dict,
Iterator,
Mapping,
Optional,
Sequence,
Type,
Union,
cast,
get_type_hints,
@@ -93,6 +87,7 @@ from langgraph.pregel.algo import (
)
from langgraph.pregel.checkpoint import create_checkpoint, empty_checkpoint
from langgraph.pregel.debug import tasks_w_writes
from langgraph.pregel.draw import draw_graph
from langgraph.pregel.io import map_input, read_channels
from langgraph.pregel.loop import AsyncPregelLoop, StreamProtocol, SyncPregelLoop
from langgraph.pregel.manager import AsyncChannelsManager, ChannelsManager
@@ -108,6 +103,7 @@ from langgraph.store.base import BaseStore
from langgraph.types import (
All,
Checkpointer,
Interrupt,
LoopProtocol,
StateSnapshot,
StateUpdate,
@@ -141,8 +137,8 @@ class Channel:
cls,
channels: str,
*,
key: Optional[str] = None,
tags: Optional[list[str]] = None,
key: str | None = None,
tags: list[str] | None = None,
) -> PregelNode: ...
@overload
@@ -152,16 +148,16 @@ class Channel:
channels: Sequence[str],
*,
key: None = None,
tags: Optional[list[str]] = None,
tags: list[str] | None = None,
) -> PregelNode: ...
@classmethod
def subscribe_to(
cls,
channels: Union[str, Sequence[str]],
channels: str | Sequence[str],
*,
key: Optional[str] = None,
tags: Optional[list[str]] = None,
key: str | None = None,
tags: list[str] | None = None,
) -> PregelNode:
"""Runs process.invoke() each time channels are updated,
with a dict of the channel values as input."""
@@ -467,7 +463,7 @@ class Pregel(PregelProtocol):
nodes: dict[str, PregelNode]
channels: dict[str, Union[BaseChannel, ManagedValueSpec]]
channels: dict[str, BaseChannel | ManagedValueSpec]
stream_mode: StreamMode = "values"
"""Mode to stream output, defaults to 'values'."""
@@ -476,18 +472,18 @@ class Pregel(PregelProtocol):
"""Whether to force emitting stream events eagerly, automatically turned on
for stream_mode "messages" and "custom"."""
output_channels: Union[str, Sequence[str]]
output_channels: str | Sequence[str]
stream_channels: Optional[Union[str, Sequence[str]]] = None
stream_channels: str | Sequence[str] | None = None
"""Channels to stream, defaults to all channels not in reserved channels"""
interrupt_after_nodes: Union[All, Sequence[str]]
interrupt_after_nodes: All | Sequence[str]
interrupt_before_nodes: Union[All, Sequence[str]]
interrupt_before_nodes: All | Sequence[str]
input_channels: Union[str, Sequence[str]]
input_channels: str | Sequence[str]
step_timeout: Optional[float] = None
step_timeout: float | None = None
"""Maximum time to wait for a step to complete, in seconds. Defaults to None."""
debug: bool
@@ -496,44 +492,44 @@ class Pregel(PregelProtocol):
checkpointer: Checkpointer = None
"""Checkpointer used to save and load graph state. Defaults to None."""
store: Optional[BaseStore] = None
store: BaseStore | None = None
"""Memory store to use for SharedValues. Defaults to None."""
retry_policy: Optional[Sequence[RetryPolicy]] = None
retry_policy: Sequence[RetryPolicy] | None = None
"""Retry policies to use when running tasks. Set to None to disable."""
config_type: Optional[Type[Any]] = None
config_type: type[Any] | None = None
input_model: Optional[Type[BaseModel]] = None
input_model: type[BaseModel] | None = None
config: Optional[RunnableConfig] = None
config: RunnableConfig | None = None
name: str = "LangGraph"
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None
def __init__(
self,
*,
nodes: dict[str, PregelNode],
channels: Optional[dict[str, Union[BaseChannel, ManagedValueSpec]]],
channels: dict[str, BaseChannel | ManagedValueSpec] | None,
auto_validate: bool = True,
stream_mode: StreamMode = "values",
stream_eager: bool = False,
output_channels: Union[str, Sequence[str]],
stream_channels: Optional[Union[str, Sequence[str]]] = None,
interrupt_after_nodes: Union[All, Sequence[str]] = (),
interrupt_before_nodes: Union[All, Sequence[str]] = (),
input_channels: Union[str, Sequence[str]],
step_timeout: Optional[float] = None,
debug: Optional[bool] = None,
checkpointer: Optional[BaseCheckpointSaver] = None,
store: Optional[BaseStore] = None,
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
config_type: Optional[Type[Any]] = None,
input_model: Optional[Type[BaseModel]] = None,
config: Optional[RunnableConfig] = None,
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
output_channels: str | Sequence[str],
stream_channels: str | Sequence[str] | None = None,
interrupt_after_nodes: All | Sequence[str] = (),
interrupt_before_nodes: All | Sequence[str] = (),
input_channels: str | Sequence[str],
step_timeout: float | None = None,
debug: bool | None = None,
checkpointer: BaseCheckpointSaver | None = None,
store: BaseStore | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
config_type: type[Any] | None = None,
input_model: type[BaseModel] | None = None,
config: RunnableConfig | None = None,
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
name: str = "LangGraph",
) -> None:
self.nodes = nodes
@@ -562,22 +558,87 @@ class Pregel(PregelProtocol):
self.validate()
def get_graph(
self, config: Optional[RunnableConfig] = None, *, xray: Union[int, bool] = False
self, config: RunnableConfig | None = None, *, xray: int | bool = False
) -> Graph:
raise NotImplementedError
"""Returns a drawable representation of the computation graph."""
# 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()
}
else:
subgraphs = {}
return draw_graph(
merge_configs(self.config, config),
nodes=self.nodes,
specs=self.channels,
input_channels=self.input_channels,
interrupt_after_nodes=self.interrupt_after_nodes,
interrupt_before_nodes=self.interrupt_before_nodes,
trigger_to_nodes=self.trigger_to_nodes,
checkpointer=self.checkpointer,
subgraphs=subgraphs,
)
async def aget_graph(
self, config: Optional[RunnableConfig] = None, *, xray: Union[int, bool] = False
self, config: RunnableConfig | None = None, *, xray: int | bool = False
) -> Graph:
raise NotImplementedError
"""Returns a drawable representation of the computation graph."""
def copy(self, update: Optional[dict[str, Any]] = None) -> Self:
# gather subgraphs
if xray:
subpregels: dict[str, PregelProtocol] = {
k: v async for k, v in self.aget_subgraphs()
}
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 = {}
return draw_graph(
merge_configs(self.config, config),
nodes=self.nodes,
specs=self.channels,
input_channels=self.input_channels,
interrupt_after_nodes=self.interrupt_after_nodes,
interrupt_before_nodes=self.interrupt_before_nodes,
trigger_to_nodes=self.trigger_to_nodes,
checkpointer=self.checkpointer,
subgraphs=subgraphs,
)
def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
"""Mime bundle used by Jupyter to display the graph"""
return {
"text/plain": repr(self),
"image/png": self.get_graph().draw_mermaid_png(),
}
def copy(self, update: dict[str, Any] | None = None) -> Self:
attrs = {**self.__dict__, **(update or {})}
return self.__class__(**attrs)
def with_config(
self, config: Optional[RunnableConfig] = None, **kwargs: Any
) -> Self:
def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self:
return self.copy(
{"config": merge_configs(self.config, config, cast(RunnableConfig, kwargs))}
)
@@ -632,9 +693,7 @@ class Pregel(PregelProtocol):
]
]
def config_schema(
self, *, include: Optional[Sequence[str]] = None
) -> Type[BaseModel]:
def config_schema(self, *, include: Sequence[str] | None = None) -> type[BaseModel]:
# If the config type is not set explicitly, we will try to infer it.
# If the config type is provided, but isn't directly supported by pydantic
# (e.g., vanilla python class), we will also delegate to the parent class,
@@ -654,8 +713,8 @@ class Pregel(PregelProtocol):
return create_model(self.get_name("Config"), field_definitions=fields)
def get_config_jsonschema(
self, *, include: Optional[Sequence[str]] = None
) -> Dict[str, Any]:
self, *, include: Sequence[str] | None = None
) -> dict[str, Any]:
schema = self.config_schema(include=include)
if hasattr(schema, "model_json_schema"):
return schema.model_json_schema()
@@ -669,9 +728,7 @@ class Pregel(PregelProtocol):
if isinstance(channel, BaseChannel):
return channel.UpdateType
def get_input_schema(
self, config: Optional[RunnableConfig] = None
) -> Type[BaseModel]:
def get_input_schema(self, config: RunnableConfig | None = None) -> type[BaseModel]:
if self.input_model is not None:
return self.input_model
config = merge_configs(self.config, config)
@@ -688,8 +745,8 @@ class Pregel(PregelProtocol):
)
def get_input_jsonschema(
self, config: Optional[RunnableConfig] = None
) -> Dict[str, Any]:
self, config: RunnableConfig | None = None
) -> dict[str, Any]:
schema = self.get_input_schema(config)
if hasattr(schema, "model_json_schema"):
return schema.model_json_schema()
@@ -704,8 +761,8 @@ class Pregel(PregelProtocol):
return channel.ValueType
def get_output_schema(
self, config: Optional[RunnableConfig] = None
) -> Type[BaseModel]:
self, config: RunnableConfig | None = None
) -> type[BaseModel]:
config = merge_configs(self.config, config)
if isinstance(self.output_channels, str):
return super().get_output_schema(config)
@@ -720,8 +777,8 @@ class Pregel(PregelProtocol):
)
def get_output_jsonschema(
self, config: Optional[RunnableConfig] = None
) -> Dict[str, Any]:
self, config: RunnableConfig | None = None
) -> dict[str, Any]:
schema = self.get_output_schema(config)
if hasattr(schema, "model_json_schema"):
return schema.model_json_schema()
@@ -736,13 +793,13 @@ class Pregel(PregelProtocol):
)
@property
def stream_channels_asis(self) -> Union[str, Sequence[str]]:
def stream_channels_asis(self) -> str | Sequence[str]:
return self.stream_channels or [
k for k in self.channels if isinstance(self.channels[k], BaseChannel)
]
def get_subgraphs(
self, *, namespace: Optional[str] = None, recurse: bool = False
self, *, namespace: str | None = None, recurse: bool = False
) -> Iterator[tuple[str, PregelProtocol]]:
for name, node in self.nodes.items():
# filter by prefix
@@ -771,7 +828,7 @@ class Pregel(PregelProtocol):
)
async def aget_subgraphs(
self, *, namespace: Optional[str] = None, recurse: bool = False
self, *, namespace: str | None = None, recurse: bool = False
) -> AsyncIterator[tuple[str, PregelProtocol]]:
for name, node in self.get_subgraphs(namespace=namespace, recurse=recurse):
yield name, node
@@ -783,8 +840,8 @@ class Pregel(PregelProtocol):
def _prepare_state_snapshot(
self,
config: RunnableConfig,
saved: Optional[CheckpointTuple],
recurse: Optional[BaseCheckpointSaver] = None,
saved: CheckpointTuple | None,
recurse: BaseCheckpointSaver | None = None,
apply_pending_writes: bool = False,
) -> StateSnapshot:
if not saved:
@@ -832,7 +889,7 @@ class Pregel(PregelProtocol):
# get the subgraphs
subgraphs = dict(self.get_subgraphs())
parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
task_states: dict[str, Union[RunnableConfig, StateSnapshot]] = {}
task_states: dict[str, RunnableConfig | StateSnapshot] = {}
for task in next_tasks.values():
if task.name not in subgraphs:
continue
@@ -899,8 +956,8 @@ class Pregel(PregelProtocol):
async def _aprepare_state_snapshot(
self,
config: RunnableConfig,
saved: Optional[CheckpointTuple],
recurse: Optional[BaseCheckpointSaver] = None,
saved: CheckpointTuple | None,
recurse: BaseCheckpointSaver | None = None,
apply_pending_writes: bool = False,
) -> StateSnapshot:
if not saved:
@@ -951,7 +1008,7 @@ class Pregel(PregelProtocol):
# get the subgraphs
subgraphs = {n: g async for n, g in self.aget_subgraphs()}
parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
task_states: dict[str, Union[RunnableConfig, StateSnapshot]] = {}
task_states: dict[str, RunnableConfig | StateSnapshot] = {}
for task in next_tasks.values():
if task.name not in subgraphs:
continue
@@ -1019,7 +1076,7 @@ class Pregel(PregelProtocol):
self, config: RunnableConfig, *, subgraphs: bool = False
) -> StateSnapshot:
"""Get the current state of the graph."""
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if not checkpointer:
@@ -1061,7 +1118,7 @@ class Pregel(PregelProtocol):
self, config: RunnableConfig, *, subgraphs: bool = False
) -> StateSnapshot:
"""Get the current state of the graph."""
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if not checkpointer:
@@ -1103,13 +1160,13 @@ class Pregel(PregelProtocol):
self,
config: RunnableConfig,
*,
filter: Optional[Dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[StateSnapshot]:
config = ensure_config(config)
"""Get the history of the state of the graph."""
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if not checkpointer:
@@ -1154,13 +1211,13 @@ class Pregel(PregelProtocol):
self,
config: RunnableConfig,
*,
filter: Optional[Dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[StateSnapshot]:
config = ensure_config(config)
"""Get the history of the state of the graph."""
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if not checkpointer:
@@ -1225,7 +1282,7 @@ class Pregel(PregelProtocol):
RunnableConfig: The updated config.
"""
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if not checkpointer:
@@ -1500,7 +1557,7 @@ class Pregel(PregelProtocol):
next_tasks[tid].writes.append((k, v))
if tasks := [t for t in next_tasks.values() if t.writes]:
apply_writes(checkpoint, channels, tasks, None)
valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = []
valid_updates: list[tuple[str, dict[str, Any] | None]] = []
if len(updates) == 1:
values, as_node = updates[0]
# find last node that updated the state, if not provided
@@ -1639,7 +1696,7 @@ class Pregel(PregelProtocol):
RunnableConfig: The updated config.
"""
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
CONFIG_KEY_CHECKPOINTER, self.checkpointer
)
if not checkpointer:
@@ -1914,7 +1971,7 @@ class Pregel(PregelProtocol):
next_tasks[tid].writes.append((k, v))
if tasks := [t for t in next_tasks.values() if t.writes]:
apply_writes(checkpoint, channels, tasks, None)
valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = []
valid_updates: list[tuple[str, dict[str, Any] | None]] = []
if len(updates) == 1:
values, as_node = updates[0]
# find last node that updated the state, if not provided
@@ -2034,8 +2091,8 @@ class Pregel(PregelProtocol):
def update_state(
self,
config: RunnableConfig,
values: Optional[Union[dict[str, Any], Any]],
as_node: Optional[str] = None,
values: dict[str, Any] | Any | None,
as_node: str | None = None,
) -> RunnableConfig:
"""Update the state of the graph with the given values, as if they came from
node `as_node`. If `as_node` is not provided, it will be set to the last node
@@ -2047,7 +2104,7 @@ class Pregel(PregelProtocol):
self,
config: RunnableConfig,
values: dict[str, Any] | Any,
as_node: Optional[str] = None,
as_node: str | None = None,
) -> RunnableConfig:
"""Update the state of the graph asynchronously with the given values, as if they came from
node `as_node`. If `as_node` is not provided, it will be set to the last node
@@ -2059,19 +2116,19 @@ class Pregel(PregelProtocol):
self,
config: RunnableConfig,
*,
stream_mode: Optional[Union[StreamMode, list[StreamMode]]],
output_keys: Optional[Union[str, Sequence[str]]],
interrupt_before: Optional[Union[All, Sequence[str]]],
interrupt_after: Optional[Union[All, Sequence[str]]],
debug: Optional[bool],
stream_mode: StreamMode | list[StreamMode] | None,
output_keys: str | Sequence[str] | None,
interrupt_before: All | Sequence[str] | None,
interrupt_after: All | Sequence[str] | None,
debug: bool | None,
) -> tuple[
bool,
set[StreamMode],
Union[str, Sequence[str]],
Union[All, Sequence[str]],
Union[All, Sequence[str]],
Optional[BaseCheckpointSaver],
Optional[BaseStore],
str | Sequence[str],
All | Sequence[str],
All | Sequence[str],
BaseCheckpointSaver | None,
BaseStore | None,
]:
if config["recursion_limit"] < 1:
raise ValueError("recursion_limit must be at least 1")
@@ -2089,7 +2146,7 @@ class Pregel(PregelProtocol):
# if being called as a node in another graph, always use values mode
stream_mode = ["values"]
if self.checkpointer is False:
checkpointer: Optional[BaseCheckpointSaver] = None
checkpointer: BaseCheckpointSaver | None = None
elif CONFIG_KEY_CHECKPOINTER in config.get(CONF, {}):
checkpointer = config[CONF][CONFIG_KEY_CHECKPOINTER]
elif self.checkpointer is True:
@@ -2101,7 +2158,7 @@ class Pregel(PregelProtocol):
f"Checkpointer requires one or more of the following 'configurable' keys: {[s.id for s in checkpointer.config_specs]}"
)
if CONFIG_KEY_STORE in config.get(CONF, {}):
store: Optional[BaseStore] = config[CONF][CONFIG_KEY_STORE]
store: BaseStore | None = config[CONF][CONFIG_KEY_STORE]
else:
store = self.store
return (
@@ -2116,17 +2173,17 @@ class Pregel(PregelProtocol):
def stream(
self,
input: Union[dict[str, Any], Any],
config: Optional[RunnableConfig] = None,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Optional[Union[StreamMode, list[StreamMode]]] = None,
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
checkpoint_during: bool | None = None,
debug: bool | None = None,
subgraphs: bool = False,
) -> Iterator[Union[dict[str, Any], Any]]:
) -> Iterator[dict[str, Any] | Any]:
"""Stream graph steps for a single input.
Args:
@@ -2135,7 +2192,7 @@ class Pregel(PregelProtocol):
stream_mode: The mode to stream output, defaults to self.stream_mode.
Options are:
- `"values"`: Emit all values in the state after each step.
- `"values"`: Emit all values in the state after each step, including interrupts.
When used with functional API, values are emitted once at the end of the workflow.
- `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
@@ -2352,7 +2409,7 @@ class Pregel(PregelProtocol):
):
# we are careful to have a single waiter live at any one time
# because on exit we increment semaphore count by exactly 1
waiter: Optional[concurrent.futures.Future] = None
waiter: concurrent.futures.Future | None = None
# because sync futures cannot be cancelled, we instead
# release the stream semaphore on exit, which will cause
# a pending waiter to return immediately
@@ -2403,17 +2460,17 @@ class Pregel(PregelProtocol):
async def astream(
self,
input: Union[dict[str, Any], Any],
config: Optional[RunnableConfig] = None,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Optional[Union[StreamMode, list[StreamMode]]] = None,
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
stream_mode: StreamMode | list[StreamMode] | None = None,
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
checkpoint_during: bool | None = None,
debug: bool | None = None,
subgraphs: bool = False,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
) -> AsyncIterator[dict[str, Any] | Any]:
"""Stream graph steps for a single input.
Args:
@@ -2422,7 +2479,7 @@ class Pregel(PregelProtocol):
stream_mode: The mode to stream output, defaults to self.stream_mode.
Options are:
- `"values"`: Emit all values in the state after each step.
- `"values"`: Emit all values in the state after each step, including interrupts.
When used with functional API, values are emitted once at the end of the workflow.
- `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
@@ -2704,17 +2761,17 @@ class Pregel(PregelProtocol):
def invoke(
self,
input: Union[dict[str, Any], Any],
config: Optional[RunnableConfig] = None,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: StreamMode = "values",
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
checkpoint_during: bool | None = None,
debug: bool | None = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
) -> dict[str, Any] | Any:
"""Run the graph with a single input and config.
Args:
@@ -2732,10 +2789,11 @@ class Pregel(PregelProtocol):
If stream_mode is not "values", it returns a list of output chunks.
"""
output_keys = output_keys if output_keys is not None else self.output_channels
if stream_mode == "values":
latest: Union[dict[str, Any], Any] = None
else:
chunks = []
latest: Union[dict[str, Any], Any] = None
chunks: list[Union[dict[str, Any], Any]] = []
interrupts: list[Interrupt] = []
for chunk in self.stream(
input,
config,
@@ -2748,27 +2806,40 @@ class Pregel(PregelProtocol):
**kwargs,
):
if stream_mode == "values":
latest = chunk
if (
isinstance(chunk, dict)
and (ints := chunk.get(INTERRUPT)) is not None
):
interrupts.extend(ints)
else:
latest = chunk
else:
chunks.append(chunk)
if stream_mode == "values":
if interrupts:
return (
{**latest, INTERRUPT: interrupts}
if isinstance(latest, dict)
else {INTERRUPT: interrupts}
)
return latest
else:
return chunks
async def ainvoke(
self,
input: Union[dict[str, Any], Any],
config: Optional[RunnableConfig] = None,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: StreamMode = "values",
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
checkpoint_during: Optional[bool] = None,
debug: Optional[bool] = None,
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
checkpoint_during: bool | None = None,
debug: bool | None = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
) -> dict[str, Any] | Any:
"""Asynchronously invoke the graph on a single input.
Args:
@@ -2787,10 +2858,11 @@ class Pregel(PregelProtocol):
"""
output_keys = output_keys if output_keys is not None else self.output_channels
if stream_mode == "values":
latest: Union[dict[str, Any], Any] = None
else:
chunks = []
latest: Union[dict[str, Any], Any] = None
chunks: list[Union[dict[str, Any], Any]] = []
interrupts: list[Interrupt] = []
async for chunk in self.astream(
input,
config,
@@ -2803,10 +2875,23 @@ class Pregel(PregelProtocol):
**kwargs,
):
if stream_mode == "values":
latest = chunk
if (
isinstance(chunk, dict)
and (ints := chunk.get(INTERRUPT)) is not None
):
interrupts.extend(ints)
else:
latest = chunk
else:
chunks.append(chunk)
if stream_mode == "values":
if interrupts:
return (
{**latest, INTERRUPT: interrupts}
if isinstance(latest, dict)
else {INTERRUPT: interrupts}
)
return latest
else:
return chunks
+15 -11
View File
@@ -3,19 +3,17 @@ import itertools
import sys
import threading
from collections import defaultdict, deque
from collections.abc import Iterable, Mapping, Sequence
from copy import copy
from functools import partial
from hashlib import sha1
from typing import (
Any,
Callable,
Iterable,
Literal,
Mapping,
NamedTuple,
Optional,
Protocol,
Sequence,
Union,
cast,
overload,
@@ -544,11 +542,14 @@ def prepare_single_task(
str(task_path[2]),
)
task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
# we append True to the task path to indicate that a call is being
# made, so we should not return interrupts from this task (responsibility lies with the parent)
task_path = (*task_path[:3], True)
metadata = {
"langgraph_step": step,
"langgraph_node": name,
"langgraph_triggers": triggers,
"langgraph_path": task_path[:3],
"langgraph_path": task_path,
"langgraph_checkpoint_ns": task_checkpoint_ns,
}
if task_id_checksum is not None:
@@ -577,7 +578,7 @@ def prepare_single_task(
local_read,
channels,
managed,
PregelTaskWrites(task_path[:3], name, writes, triggers),
PregelTaskWrites(task_path, name, writes, triggers),
),
CONFIG_KEY_STORE: (store or configurable.get(CONFIG_KEY_STORE)),
CONFIG_KEY_CHECKPOINTER: (
@@ -600,10 +601,10 @@ def prepare_single_task(
call.retry,
None,
task_id,
task_path[:3],
task_path,
)
else:
return PregelTask(task_id, name, task_path[:3])
return PregelTask(task_id, name, task_path)
elif task_path[0] == PUSH:
if len(task_path) == 2:
# SEND tasks, executed in superstep n+1
@@ -639,11 +640,14 @@ def prepare_single_task(
logger.warning(f"Ignoring invalid PUSH task path {task_path}")
return
task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
# we append False to the task path to indicate that a call is not being made
# so we should return interrupts from this task
task_path = (*task_path[:3], False)
metadata = {
"langgraph_step": step,
"langgraph_node": packet.node,
"langgraph_triggers": triggers,
"langgraph_path": task_path[:3],
"langgraph_path": task_path,
"langgraph_checkpoint_ns": task_checkpoint_ns,
}
if task_id_checksum is not None:
@@ -680,7 +684,7 @@ def prepare_single_task(
channels,
managed,
PregelTaskWrites(
task_path[:3], packet.node, writes, triggers
task_path, packet.node, writes, triggers
),
),
CONFIG_KEY_STORE: (
@@ -710,12 +714,12 @@ def prepare_single_task(
proc.retry_policy,
None,
task_id,
task_path[:3],
task_path,
writers=proc.flat_writers,
subgraphs=proc.subgraphs,
)
else:
return PregelTask(task_id, packet.node, task_path[:3])
return PregelTask(task_id, packet.node, task_path)
elif task_path[0] == PULL:
# (PULL, node name)
name = cast(str, task_path[1])
+4 -7
View File
@@ -5,7 +5,8 @@ import functools
import inspect
import sys
import types
from typing import Any, Callable, Generator, Generic, Optional, Sequence, TypeVar, cast
from collections.abc import Generator, Sequence
from typing import Any, Callable, Generic, Optional, TypeVar, cast
from langchain_core.runnables import Runnable
from typing_extensions import ParamSpec
@@ -29,16 +30,12 @@ from langgraph.utils.runnable import (
def _getattribute(obj: Any, name: str) -> Any:
for subpath in name.split("."):
if subpath == "<locals>":
raise AttributeError(
"Can't get local attribute {!r} on {!r}".format(name, obj)
)
raise AttributeError(f"Can't get local attribute {name!r} on {obj!r}")
try:
parent = obj
obj = getattr(obj, subpath)
except AttributeError:
raise AttributeError(
"Can't get attribute {!r} on {!r}".format(name, obj)
) from None
raise AttributeError(f"Can't get attribute {name!r} on {obj!r}") from None
return obj, parent
@@ -1,5 +1,6 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Mapping, Optional
from typing import Optional
from langgraph.channels.base import BaseChannel
from langgraph.checkpoint.base import Checkpoint
+1 -4
View File
@@ -1,15 +1,12 @@
from collections import defaultdict
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import asdict
from datetime import datetime, timezone
from pprint import pformat
from typing import (
Any,
Iterable,
Iterator,
Literal,
Mapping,
Optional,
Sequence,
Union,
)
from uuid import UUID
+239
View File
@@ -0,0 +1,239 @@
from collections import defaultdict
from collections.abc import Mapping, Sequence
from typing import Any, Optional, Union, cast
from langchain_core.runnables.config import RunnableConfig
from langchain_core.runnables.graph import Graph, Node
from langgraph.channels.base import BaseChannel
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.constants import CONF, CONFIG_KEY_SEND, END, INPUT, START
from langgraph.managed.base import ManagedValueSpec
from langgraph.pregel.algo import (
PregelTaskWrites,
apply_writes,
increment,
prepare_next_tasks,
)
from langgraph.pregel.checkpoint import empty_checkpoint
from langgraph.pregel.io import map_input
from langgraph.pregel.manager import ChannelsManager
from langgraph.pregel.read import PregelNode
from langgraph.pregel.write import ChannelWrite
from langgraph.types import All, Checkpointer, LoopProtocol
def draw_graph(
config: RunnableConfig,
*,
nodes: dict[str, PregelNode],
specs: dict[str, Union[BaseChannel, ManagedValueSpec]],
input_channels: Union[str, Sequence[str]],
interrupt_after_nodes: Union[All, Sequence[str]],
interrupt_before_nodes: Union[All, Sequence[str]],
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]],
checkpointer: Checkpointer,
subgraphs: dict[str, Graph],
) -> Graph:
"""Get the graph for this Pregel instance.
Args:
config: The configuration to use for the graph.
subgraphs: The subgraphs to include in the graph.
checkpointer: The checkpointer to use for the graph.
Returns:
The graph for this Pregel instance.
"""
# (src, dest, is_conditional, label)
edges: set[tuple[str, str, bool, Optional[str]]] = set()
step = -1
checkpoint = empty_checkpoint()
get_next_version = (
checkpointer.get_next_version
if isinstance(checkpointer, BaseCheckpointSaver)
else increment
)
with ChannelsManager(
specs,
checkpoint,
LoopProtocol(step=step, stop=-1, config=config),
skip_context=True,
) as (channels, managed):
static_seen: set[Any] = set()
sources: dict[str, set[tuple[str, bool, Optional[str]]]] = {}
step_sources: dict[str, set[tuple[str, bool, Optional[str]]]] = {}
# remove node mappers
nodes = {
k: v.copy(update={"mapper": None}) if v.mapper is not None else v
for k, v in nodes.items()
}
# apply input writes
input_writes = list(map_input(input_channels, {}))
_, updated_channels = apply_writes(
checkpoint,
channels,
[
PregelTaskWrites((), INPUT, input_writes, []),
],
get_next_version,
)
# prepare first tasks
tasks = prepare_next_tasks(
checkpoint,
[],
nodes,
channels,
managed,
config,
step,
for_execution=True,
store=None,
checkpointer=None,
manager=None,
trigger_to_nodes=trigger_to_nodes,
updated_channels=updated_channels,
)
start_tasks = tasks
# run the pregel loop
while tasks:
conditionals: dict[tuple[str, str, Any], Optional[str]] = {}
# run task writers
for task in tasks.values():
for w in task.writers:
# apply regular writes
if isinstance(w, ChannelWrite):
w.invoke(None, task.config)
# apply conditional writes declared for static analysis, only once
if w not in static_seen:
static_seen.add(w)
# apply static writes
if writes := ChannelWrite.get_static_writes(w):
# END writes are not written, but become edges directly
for t in writes:
if t[0] == END:
edges.add((task.name, t[0], True, t[2]))
writes = [t for t in writes if t[0] != END]
conditionals.update(
{(task.name, *t[:2]): t[2] for t in writes}
)
task.config[CONF][CONFIG_KEY_SEND]([t[:2] for t in writes])
# collect sources
step_sources = {
task.name: {
(
w[0],
(task.name, *w) in conditionals,
conditionals.get((task.name, *w)),
)
for w in task.writes
}
for task in tasks.values()
}
sources.update(step_sources)
# invert triggers
trigger_to_sources: dict[str, set[tuple[str, bool, Optional[str]]]] = (
defaultdict(set)
)
for src, triggers in sources.items():
for trigger, cond, label in triggers:
trigger_to_sources[trigger].add((src, cond, label))
# apply writes
_, updated_channels = apply_writes(
checkpoint, channels, tasks.values(), get_next_version
)
# prepare next tasks
tasks = prepare_next_tasks(
checkpoint,
[],
nodes,
channels,
managed,
config,
step,
for_execution=True,
store=None,
checkpointer=None,
manager=None,
trigger_to_nodes=trigger_to_nodes,
updated_channels=updated_channels,
)
# collect edges
for task in tasks.values():
for trigger in task.triggers:
for src, cond, label in sorted(trigger_to_sources[trigger]):
edges.add((src, task.name, cond, label))
# assemble the graph
graph = Graph()
# add nodes
for name, node in nodes.items():
metadata = dict(node.metadata or {})
if name in interrupt_before_nodes and name in interrupt_after_nodes:
metadata["__interrupt"] = "before,after"
elif name in interrupt_before_nodes:
metadata["__interrupt"] = "before"
elif name in interrupt_after_nodes:
metadata["__interrupt"] = "after"
graph.add_node(node.bound, name, metadata=metadata or None)
# add start node
if START not in nodes:
graph.add_node(None, START)
for task in start_tasks.values():
add_edge(graph, START, task.name)
# add discovered edges
for src, dest, is_conditional, label in sorted(edges):
add_edge(
graph,
src,
dest,
data=label if label != dest else None,
conditional=is_conditional,
)
# add end edges
termini = {d for _, d, _, _ in edges if d != END}.difference(
s for s, _, _, _ in edges
)
if termini:
for src in sorted(termini):
add_edge(graph, src, END)
elif len(step_sources) == 1:
for src in sorted(step_sources):
add_edge(graph, src, END, conditional=True)
# replace subgraphs
for name, subgraph in subgraphs.items():
if (
len(subgraph.nodes) > 1
and name in graph.nodes
and subgraph.first_node()
and subgraph.last_node()
):
subgraph.trim_first_node()
subgraph.trim_last_node()
# replace the node with the subgraph
graph.nodes.pop(name)
first, last = graph.extend(subgraph, prefix=name)
for idx, edge in enumerate(graph.edges):
if edge.source == name:
graph.edges[idx] = edge.copy(source=cast(Node, last).id)
elif edge.target == name:
graph.edges[idx] = edge.copy(target=cast(Node, first).id)
return graph
def add_edge(
graph: Graph,
source: str,
target: str,
*,
data: Optional[Any] = None,
conditional: bool = False,
) -> None:
"""Add an edge to the graph."""
for edge in graph.edges:
if edge.source == source and edge.target == target:
return
if target not in graph.nodes and target == END:
graph.add_node(None, END)
graph.add_edge(graph.nodes[source], graph.nodes[target], data, conditional)
+4 -7
View File
@@ -1,15 +1,12 @@
import asyncio
import concurrent.futures
import time
from contextlib import ExitStack
from collections.abc import Awaitable, Coroutine
from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
from contextvars import copy_context
from types import TracebackType
from typing import (
AsyncContextManager,
Awaitable,
Callable,
ContextManager,
Coroutine,
Optional,
Protocol,
TypeVar,
@@ -40,7 +37,7 @@ class Submit(Protocol[P, T]):
) -> concurrent.futures.Future[T]: ...
class BackgroundExecutor(ContextManager):
class BackgroundExecutor(AbstractContextManager):
"""A context manager that runs sync tasks in the background.
Uses a thread pool executor to delegate tasks to separate threads.
On exit,
@@ -122,7 +119,7 @@ class BackgroundExecutor(ContextManager):
pass
class AsyncBackgroundExecutor(AsyncContextManager):
class AsyncBackgroundExecutor(AbstractAsyncContextManager):
"""A context manager that runs async tasks in the background.
Uses the current event loop to delegate tasks to asyncio tasks.
On exit,
+2 -1
View File
@@ -1,5 +1,6 @@
from collections import Counter
from typing import Any, Iterator, Literal, Mapping, Optional, Sequence, TypeVar, Union
from collections.abc import Iterator, Mapping, Sequence
from typing import Any, Literal, Optional, TypeVar, Union
from uuid import UUID
from langchain_core.runnables.utils import AddableDict

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