Compare commits

..
Author SHA1 Message Date
Sydney Runkle f3e4f4f962 release(langgraph): 1.1.10 2026-04-27 13:04:02 -04:00
William FHandGitHub d177a0db43 Revert "chore: node-level timeouts" (#7627)
Reverts langchain-ai/langgraph#7599

I am going to implement this as an `idle_timeout` instead. I think
that's a better default behavior.
2026-04-27 09:03:19 -07:00
Sydney RunkleandGitHub 372d54dc4f release(checkpoint): 4.0.3 (#7625)
## Summary

Bumps `langgraph-checkpoint` `4.0.2` → `4.0.3` and updates all
downstream `uv.lock` files.

## Changes since 4.0.2

- fix(checkpoint): revive lc=2 JSON blobs for safe types without
allowlist (#7582)
- chore: dedup warnings (#7257)
- chore(deps): bump langsmith from 0.6.4 to 0.7.31 (#7525)
2026-04-27 10:27:56 -04:00
f4aee546ad fix(prebuilt): hydrate ToolNode state from channels via pregel helpers (#7594)
## Summary

When `ToolNode` receives a bare `[tool_call]` list via the Send API (the
dispatch shape `create_agent` will use once langchain-ai/langchain#36960
lands), hydrate `ToolRuntime.state` from the current channel values
instead of requiring the dispatcher to inline the full agent state dict
into every `Send.arg`.

Motivation: the paired langchain PR drops the `ToolCallWithContext`
wrapper from `create_agent`'s tool dispatch, which eliminates an O(N²)
storage term on `__pregel_tasks` checkpoint writes. Without this
companion change there would be no path for the tool node to see the
graph state.

## What changed

- `libs/prebuilt/langgraph/prebuilt/tool_node.py` — `_extract_state`
grows a third branch for list-form input. When the input is a list whose
last entry is a `ToolCall` dict, read the current channel values via
`CONFIG_KEY_READ` and return them as the state dict.

The full new logic is four lines inline in `_extract_state`:

```python
read = config.get(CONF, {}).get(CONFIG_KEY_READ)
if read is None:
    return {}
# Pregel installs CONFIG_KEY_READ as
# `functools.partial(local_read, scratchpad, channels, managed, task)`.
channels = read.args[1]
return cast("dict[str, Any]", read(list(channels), False))
```

- No changes to the pregel read machinery (`local_read`, `ChannelRead`).
- Only channel values are read; managed values have their own injection
path (`ToolRuntime.context`, `InjectedContext`) and were never in the
pre-fix inlined state dict, so we don't add them here.
- Falls back to `{}` when invoked outside a Pregel context (e.g. direct
`ToolNode(...).invoke([tool_call])` from a test harness), which
preserves existing `ToolNode` direct-invocation test behavior.

- `libs/prebuilt/tests/test_on_tool_call.py` — two new tests covering
the list-form hydration path (sync + async). They build a
`functools.partial` that matches Pregel's real `CONFIG_KEY_READ` shape
and assert `ToolRuntime.state` reflects the current channel values.

## Why it's safe

- **Same snapshot semantics as before.** `Send` is emitted at
end-of-super-step-N; consumed at start-of-super-step-N+1. Channels at
that point reflect every write from super-step N (including the new
AIMessage the tool calls originated from). Parallel tool tasks in the
tools super-step all read the same values since sibling writes don't
land until end-of-super-step.
- **Legacy `ToolCallWithContext` path preserved.** External dispatchers
that still inline state continue to work unchanged — `_extract_state`
checks that branch first.

## Test plan

- [x] `make test` in `libs/prebuilt` — **204 pass**
- [x] Two new hydration tests (sync + async) green
- [x] `make format` / `make lint` / `mypy` clean

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:52:23 -04:00
85cd64ed69 fix(checkpoint): revive lc=2 JSON blobs for safe types without allowlist (#7582)
## Summary

Fixes #7498 — `MESSAGE_COERCION_FAILURE` when resuming threads
checkpointed before v1.0.1.

**Root cause:** PR #6269 (v1.0.1) added an `_allowed_json_modules`
security gate to `JsonPlusSerializer._reviver`. The gate defaults to
`None`, so old `"json"`-format checkpoint blobs containing `lc=2`
constructor dicts (the pre-msgpack serialization format for pydantic
objects like `HumanMessage`) are now returned as raw dicts instead of
being reconstructed. Those raw dicts reach `add_messages →
convert_to_messages`, which sees `type="constructor"` and raises
`MESSAGE_COERCION_FAILURE`. Fresh first-turn messages are unaffected
because current `dumps_typed` only writes `"msgpack"` blobs.

**Fix:** `_reviver` now reconstructs `lc=2` blobs whose target class is
already in `SAFE_MSGPACK_TYPES` — the same curated allowlist already
used by the msgpack deserialization path (includes all standard
LangChain message types). Unknown classes are still blocked, preserving
the security intent of #6269.

## Changes

- `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py` — add
`_is_safe_json_type()` helper; update `_reviver` and
`_check_allowed_json_modules` to allow safe types without an explicit
allowlist
- `libs/checkpoint/tests/test_jsonplus.py` — two new regression tests:
safe-type `lc=2` blobs revive correctly; unknown-type `lc=2` blobs stay
blocked

## Test plan

- [ ] `test_lc2_json_safe_type_revives_without_allowlist` —
`HumanMessage`/`AIMessage` lc=2 JSON blobs round-trip to proper
`BaseMessage` objects with no allowlist configured
- [ ] `test_lc2_json_unknown_type_stays_blocked_without_allowlist` —
`pprint.pprint` lc=2 blob still returns raw dict (not reconstructed)
- [ ] `test_deserde_invalid_module` — existing behaviour unchanged
- [ ] Full `test_jsonplus.py` suite: 93/93 passing

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:08:47 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
53a9806e65 chore(deps): bump nbconvert from 7.17.0 to 7.17.1 in /libs/langgraph (#7573)
Bumps [nbconvert](https://github.com/jupyter/nbconvert) from 7.17.0 to
7.17.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jupyter/nbconvert/releases">nbconvert's
releases</a>.</em></p>
<blockquote>
<h2>v7.17.1</h2>
<h2>7.17.1</h2>
<p>This is a security release, fixing two CVEs:</p>
<ul>
<li><a
href="https://github.com/jupyter/nbconvert/security/advisories/GHSA-4c99-qj7h-p3vg">CVE-2026-39377</a></li>
<li><a
href="https://github.com/jupyter/nbconvert/security/advisories/GHSA-7jqv-fw35-gmx9">CVE-2026-39378</a></li>
</ul>
<p>(full advisories will be published seven days after release, on
2026-04-14).</p>
<p>(<a
href="https://github.com/jupyter/nbconvert/compare/v7.17.0...b3b6ec01f872e9af8fd1769eb9cf1889c720ecf3">Full
Changelog</a>)</p>
<h3>Enhancements made</h3>
<ul>
<li>Allow configureable WebPDF JavaScript processing timeout <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2250">#2250</a>
(<a href="https://github.com/timkpaine"><code>@​timkpaine</code></a>, <a
href="https://github.com/Carreau"><code>@​Carreau</code></a>)</li>
</ul>
<h3>Bugs fixed</h3>
<ul>
<li>Fix <code>PermissionError</code> when checking template paths on
shared filesystems <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2252">#2252</a>
(<a href="https://github.com/ctcjab"><code>@​ctcjab</code></a>, <a
href="https://github.com/krassowski"><code>@​krassowski</code></a>)</li>
<li>Tweak webpdf template logic to fix duplicate extension problem <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2249">#2249</a>
(<a href="https://github.com/timkpaine"><code>@​timkpaine</code></a>, <a
href="https://github.com/Carreau"><code>@​Carreau</code></a>)</li>
</ul>
<h3>Maintenance and upkeep improvements</h3>
<ul>
<li>specify python version for pre <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2276">#2276</a>
(<a href="https://github.com/minrk"><code>@​minrk</code></a>, <a
href="https://github.com/krassowski"><code>@​krassowski</code></a>)</li>
</ul>
<h3>Contributors to this release</h3>
<p>The following people contributed discussions, new ideas, code and
documentation contributions, and review.
See <a
href="https://github-activity.readthedocs.io/en/latest/use/#how-does-this-tool-define-contributions-in-the-reports">our
definition of contributors</a>.</p>
<p>(<a
href="https://github.com/jupyter/nbconvert/graphs/contributors?from=2026-01-29&amp;to=2026-04-08&amp;type=c">GitHub
contributors page for this release</a>)</p>
<p><a href="https://github.com/akhmerov"><code>@​akhmerov</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Aakhmerov+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/bollwyvl"><code>@​bollwyvl</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Abollwyvl+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/Carreau"><code>@​Carreau</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3ACarreau+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/ctcjab"><code>@​ctcjab</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Actcjab+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a
href="https://github.com/davidbrochart"><code>@​davidbrochart</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Adavidbrochart+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/Ken-B"><code>@​Ken-B</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3AKen-B+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/krassowski"><code>@​krassowski</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Akrassowski+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/mgeier"><code>@​mgeier</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Amgeier+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/minrk"><code>@​minrk</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Aminrk+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/mpacer"><code>@​mpacer</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Ampacer+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/MSeal"><code>@​MSeal</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3AMSeal+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a
href="https://github.com/SylvainCorlay"><code>@​SylvainCorlay</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3ASylvainCorlay+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/takluyver"><code>@​takluyver</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Atakluyver+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/timkpaine"><code>@​timkpaine</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Atimkpaine+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/jupyter/nbconvert/blob/main/CHANGELOG.md">nbconvert's
changelog</a>.</em></p>
<blockquote>
<h2>7.17.1</h2>
<p>This is a security release, fixing two CVEs:</p>
<ul>
<li><a
href="https://github.com/jupyter/nbconvert/security/advisories/GHSA-4c99-qj7h-p3vg">CVE-2026-39377</a></li>
<li><a
href="https://github.com/jupyter/nbconvert/security/advisories/GHSA-7jqv-fw35-gmx9">CVE-2026-39378</a></li>
</ul>
<p>(full advisories will be published seven days after release, on
2026-04-14).</p>
<p>(<a
href="https://github.com/jupyter/nbconvert/compare/v7.17.0...b3b6ec01f872e9af8fd1769eb9cf1889c720ecf3">Full
Changelog</a>)</p>
<h3>Enhancements made</h3>
<ul>
<li>Allow configureable WebPDF JavaScript processing timeout <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2250">#2250</a>
(<a href="https://github.com/timkpaine"><code>@​timkpaine</code></a>, <a
href="https://github.com/Carreau"><code>@​Carreau</code></a>)</li>
</ul>
<h3>Bugs fixed</h3>
<ul>
<li>Fix <code>PermissionError</code> when checking template paths on
shared filesystems <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2252">#2252</a>
(<a href="https://github.com/ctcjab"><code>@​ctcjab</code></a>, <a
href="https://github.com/krassowski"><code>@​krassowski</code></a>)</li>
<li>Tweak webpdf template logic to fix duplicate extension problem <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2249">#2249</a>
(<a href="https://github.com/timkpaine"><code>@​timkpaine</code></a>, <a
href="https://github.com/Carreau"><code>@​Carreau</code></a>)</li>
</ul>
<h3>Maintenance and upkeep improvements</h3>
<ul>
<li>specify python version for pre <a
href="https://redirect.github.com/jupyter/nbconvert/pull/2276">#2276</a>
(<a href="https://github.com/minrk"><code>@​minrk</code></a>, <a
href="https://github.com/krassowski"><code>@​krassowski</code></a>)</li>
</ul>
<h3>Contributors to this release</h3>
<p>The following people contributed discussions, new ideas, code and
documentation contributions, and review.
See <a
href="https://github-activity.readthedocs.io/en/latest/use/#how-does-this-tool-define-contributions-in-the-reports">our
definition of contributors</a>.</p>
<p>(<a
href="https://github.com/jupyter/nbconvert/graphs/contributors?from=2026-01-29&amp;to=2026-04-08&amp;type=c">GitHub
contributors page for this release</a>)</p>
<p><a href="https://github.com/akhmerov"><code>@​akhmerov</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Aakhmerov+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/bollwyvl"><code>@​bollwyvl</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Abollwyvl+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/Carreau"><code>@​Carreau</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3ACarreau+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/ctcjab"><code>@​ctcjab</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Actcjab+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a
href="https://github.com/davidbrochart"><code>@​davidbrochart</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Adavidbrochart+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/Ken-B"><code>@​Ken-B</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3AKen-B+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/krassowski"><code>@​krassowski</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Akrassowski+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/mgeier"><code>@​mgeier</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Amgeier+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/minrk"><code>@​minrk</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Aminrk+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/mpacer"><code>@​mpacer</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Ampacer+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/MSeal"><code>@​MSeal</code></a> (<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3AMSeal+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a
href="https://github.com/SylvainCorlay"><code>@​SylvainCorlay</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3ASylvainCorlay+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/takluyver"><code>@​takluyver</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Atakluyver+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)
| <a href="https://github.com/timkpaine"><code>@​timkpaine</code></a>
(<a
href="https://github.com/search?q=repo%3Ajupyter%2Fnbconvert+involves%3Atimkpaine+updated%3A2026-01-29..2026-04-08&amp;type=Issues">activity</a>)</p>
<!-- raw HTML omitted -->
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/jupyter/nbconvert/commit/78ed30837a607deab7cf0a12dca072bf3f63417a"><code>78ed308</code></a>
Publish 7.17.1</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/f090a64606fde4c7f87cbf478f51b4aa46a425ec"><code>f090a64</code></a>
ruff format</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/b3b6ec01f872e9af8fd1769eb9cf1889c720ecf3"><code>b3b6ec0</code></a>
chore: update pre-commit hooks (<a
href="https://redirect.github.com/jupyter/nbconvert/issues/2277">#2277</a>)</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/be4841f7da51c499b1937e41e6e71926dbe0daa3"><code>be4841f</code></a>
ignore silly security lint in tests</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/26d57b295870f5572d9bd0535acee4a120339c10"><code>26d57b2</code></a>
fix type annotation on Lexer</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/0e6b8ccabf2aca6c18fac8c574f22b7155f441fb"><code>0e6b8cc</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/ba5e5cdd737704388251fa55fa9e58f5752fa39d"><code>ba5e5cd</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/1db0c88d865146ce02b6405a8d96753d3d0cd0c2"><code>1db0c88</code></a>
Specify python version for pre (<a
href="https://redirect.github.com/jupyter/nbconvert/issues/2276">#2276</a>)</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/7473fc3037a6317bff54380e3a7162d73bf089b3"><code>7473fc3</code></a>
chore: update pre-commit hooks (<a
href="https://redirect.github.com/jupyter/nbconvert/issues/2242">#2242</a>)</li>
<li><a
href="https://github.com/jupyter/nbconvert/commit/4322f7f290694929f414cefadc942111afad3762"><code>4322f7f</code></a>
Bump the actions group across 1 directory with 2 updates (<a
href="https://redirect.github.com/jupyter/nbconvert/issues/2273">#2273</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/jupyter/nbconvert/compare/v7.17.0...v7.17.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nbconvert&package-manager=uv&previous-version=7.17.0&new-version=7.17.1)](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 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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 20:30:47 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
219fbbe8d0 chore(deps): bump python-dotenv from 1.2.1 to 1.2.2 in /libs/langgraph (#7574)
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from
1.2.1 to 1.2.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/theskumar/python-dotenv/releases">python-dotenv's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.2</h2>
<h3>Added</h3>
<ul>
<li>Support for Python 3.14, including the free-threaded (3.14t) build.
(#)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>The <code>dotenv run</code> command now forwards flags directly to
the specified command by <a
href="https://github.com/bbc2"><code>@​bbc2</code></a> in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/607">theskumar/python-dotenv#607</a></li>
<li>Improved documentation clarity regarding override behavior and the
reference page.</li>
<li>Updated PyPy support to version 3.11.</li>
<li>Documentation for FIFO file support.</li>
<li>Support for Python 3.9.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Improved <code>set_key</code> and <code>unset_key</code> behavior
when interacting with symlinks by <a
href="https://github.com/bbc2"><code>@​bbc2</code></a> in <a
href="https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311">#790c5</a></li>
<li>Corrected the license specifier and added missing Python 3.14
classifiers in package metadata by <a
href="https://github.com/JYOuyang"><code>@​JYOuyang</code></a> in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/590">theskumar/python-dotenv#590</a></li>
</ul>
<h3>Breaking Changes</h3>
<ul>
<li>
<p><code>dotenv.set_key</code> and <code>dotenv.unset_key</code> used to
follow symlinks in some
situations. This is no longer the case. For that behavior to be restored
in
all cases, <code>follow_symlinks=True</code> should be used.</p>
</li>
<li>
<p>In the CLI, <code>set</code> and <code>unset</code> used to follow
symlinks in some situations. This
is no longer the case.</p>
</li>
<li>
<p><code>dotenv.set_key</code>, <code>dotenv.unset_key</code> and the
CLI commands <code>set</code> and <code>unset</code>
used to reset the file mode of the modified .env file to
<code>0o600</code> in some
situations. This is no longer the case: The original mode of the file is
now
preserved. Is the file needed to be created or wasn't a regular file,
mode
<code>0o600</code> is used.</p>
</li>
</ul>
<h3>Misc</h3>
<ul>
<li>skip 000 permission tests for root user by <a
href="https://github.com/burnout-projects"><code>@​burnout-projects</code></a>
in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/561">theskumar/python-dotenv#561</a></li>
<li>Bump actions/checkout from 5 to 6 in the github-actions group by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/593">theskumar/python-dotenv#593</a></li>
<li>Add Windows testing to CI by <a
href="https://github.com/bbc2"><code>@​bbc2</code></a> in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/604">theskumar/python-dotenv#604</a></li>
<li>Improve workflow efficiency with best practices by <a
href="https://github.com/theskumar"><code>@​theskumar</code></a> in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/609">theskumar/python-dotenv#609</a></li>
<li>Remove the use of <code>sh</code> in tests by <a
href="https://github.com/bbc2"><code>@​bbc2</code></a> in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/612">theskumar/python-dotenv#612</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/JYOuyang"><code>@​JYOuyang</code></a>
made their first contribution in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/590">theskumar/python-dotenv#590</a></li>
<li><a
href="https://github.com/burnout-projects"><code>@​burnout-projects</code></a>
made their first contribution in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/561">theskumar/python-dotenv#561</a></li>
<li><a
href="https://github.com/cpackham-atlnz"><code>@​cpackham-atlnz</code></a>
made their first contribution in <a
href="https://redirect.github.com/theskumar/python-dotenv/pull/597">theskumar/python-dotenv#597</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2">https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md">python-dotenv's
changelog</a>.</em></p>
<blockquote>
<h2>[1.2.2] - 2026-03-01</h2>
<h3>Added</h3>
<ul>
<li>Support for Python 3.14, including the free-threaded (3.14t) build.
(<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/588">#588</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>The <code>dotenv run</code> command now forwards flags directly to
the specified command by [<a
href="https://github.com/bbc2"><code>@​bbc2</code></a>] in <a
href="https://redirect.github.com/theskumar/python-dotenv/issues/607">#607</a></li>
<li>Improved documentation clarity regarding override behavior and the
reference page.</li>
<li>Updated PyPy support to version 3.11.</li>
<li>Documentation for FIFO file support.</li>
<li>Dropped Support for Python 3.9.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Improved <code>set_key</code> and <code>unset_key</code> behavior
when interacting with symlinks by [<a
href="https://github.com/bbc2"><code>@​bbc2</code></a>] in
[790c5c0]</li>
<li>Corrected the license specifier and added missing Python 3.14
classifiers in package metadata by [<a
href="https://github.com/JYOuyang"><code>@​JYOuyang</code></a>] in <a
href="https://redirect.github.com/theskumar/python-dotenv/issues/590">#590</a></li>
</ul>
<h3>Breaking Changes</h3>
<ul>
<li>
<p><code>dotenv.set_key</code> and <code>dotenv.unset_key</code> used to
follow symlinks in some
situations. This is no longer the case. For that behavior to be restored
in
all cases, <code>follow_symlinks=True</code> should be used.</p>
</li>
<li>
<p>In the CLI, <code>set</code> and <code>unset</code> used to follow
symlinks in some situations. This
is no longer the case.</p>
</li>
<li>
<p><code>dotenv.set_key</code>, <code>dotenv.unset_key</code> and the
CLI commands <code>set</code> and <code>unset</code>
used to reset the file mode of the modified .env file to
<code>0o600</code> in some
situations. This is no longer the case: The original mode of the file is
now
preserved. Is the file needed to be created or wasn't a regular file,
mode
<code>0o600</code> is used.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/36004e0e34be7665ff2b11a8a4005144f76f176d"><code>36004e0</code></a>
Bump version: 1.2.1 → 1.2.2</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/eb202520e5933c9daf42501e1e42fdb0144002c8"><code>eb20252</code></a>
docs: update changelog for v1.2.2</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311"><code>790c5c0</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/43340da220fb4ca4f95357bbe21a3c7f8f1278b1"><code>43340da</code></a>
Remove the use of <code>sh</code> in tests (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/612">#612</a>)</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/09d7cee32459e7abdcb5c9d8122a552589c06a9c"><code>09d7cee</code></a>
docs: clarify override behavior and document FIFO support (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/610">#610</a>)</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/c8de2887c00198c22842c5ae5e92d1747467363c"><code>c8de288</code></a>
ci: improve workflow efficiency with best practices (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/609">#609</a>)</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/7bd9e3dbfedc0983ad7d56d5570013035242bdf4"><code>7bd9e3d</code></a>
Add Windows testing to CI (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/604">#604</a>)</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/1baaf04f336072e0ee324d5df9563ec767f14f81"><code>1baaf04</code></a>
Drop Python 3.9 support and update to PyPy 3.11 (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/608">#608</a>)</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/4a22cf8993804aeede0c20b75bb1a29d3a99e9dc"><code>4a22cf8</code></a>
ci: enable testing on Python 3.14t (free-threaded) (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/588">#588</a>)</li>
<li><a
href="https://github.com/theskumar/python-dotenv/commit/e2e8e776b42e382ae38b44d3982dd649e7507dd4"><code>e2e8e77</code></a>
Fix license specifier (<a
href="https://redirect.github.com/theskumar/python-dotenv/issues/597">#597</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python-dotenv&package-manager=uv&previous-version=1.2.1&new-version=1.2.2)](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 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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 20:30:29 -07:00
aeff9549c2 chore: node-level timeouts (#7599)
This PR implements task/node-level timeouts. 

Since python has a terrible multi-processing model, we make two
concessions:
- we only support for async functions/nodes. Sync nodes with a timeout
raise an error at compile time
- we implement this with asyncio wait_for in the async path.
 
Each timed attempt is wrapped in _retry.py, and the timer is reset on
each node-level retry. When the deadline is exceeded LangGraph raises
NodeTimeoutError, clears buffered writes, and prevents any late writes
or child-task scheduling from leaking past the timeout via
_TimedAttemptScope.

The design also adds a timed-attempt observer hook
(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER) that emits start/finish events with
identifiers and deadlines. This means that if you have an orchestrating
process starting a worker process, it can listen to start/end events and
hard-kill the process to enforce a timeout if there is a deadlock.

---------

Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
2026-04-24 18:27:22 -07:00
ccurmeandGitHub 1a248cba45 release(prebuilt): 1.0.11 (#7610) 2026-04-24 14:16:17 -04:00
45246f6c74 feat(prebuilt): allow ToolNode tools to return list[Command | ToolMessage] (#7596)
## Summary

Extends `ToolNode` so that a single tool invocation can return
`list[Command | ToolMessage]` instead of only a single `Command` or
`ToolMessage`. This brings `ToolNode`'s per-tool-call contract in line
with the rest of LangGraph, where nodes can already return multiple
Commands.

Depends on langchain-ai/langchain#36963 which allows
`list[ToolOutputMixin]` to pass through `BaseTool._format_output`
unchanged.

## Changes

### `libs/prebuilt/langgraph/prebuilt/tool_node.py`

**New list-return gate in `_execute_tool_sync` / `_execute_tool_async`**
— After the existing `Command` and `ToolMessage` checks, a new branch
accepts `list[Command | ToolMessage]` and routes it through
`_validate_tool_command_list`. Lists with non-`Command`/`ToolMessage`
elements raise `TypeError`. Both sync and async paths are updated
symmetrically.

**`_validate_tool_command_list`** — Enforces the terminating-ToolMessage
rule: exactly one `ToolMessage` in the list must carry `tool_call_id ==
<outer_id>` (top-level or nested inside a `Command.update["messages"]`).
Zero or multiple terminators raise `_MissingToolMessageError`.
Individual Commands in the list are validated via the existing
`_validate_tool_command`; when a Command lacks the terminator (which is
allowed since the list-level check handles it), the
`_MissingToolMessageError` is caught and the already-normalized command
from the exception is used.

**`_MissingToolMessageError`** — A `ValueError` subclass raised by
`_validate_tool_command` (and `_validate_tool_command_list`) when no
matching `ToolMessage` is found. Carries the already-normalized command
so callers can recover without re-doing deepcopy/message-conversion
work. Using a typed exception avoids brittle string-matching on error
messages.

**`_combine_tool_outputs`** — Flattens list entries at the top of the
method so downstream combiner logic (parent-`goto` accumulation,
ToolMessage wrapping) is unchanged.

**Response processing moved inside try/except** — In both sync and async
execute methods, the response validation (Command/ToolMessage/list
checks) now runs inside the existing error-handling try block, so
validation errors from the list path go through `_handle_tool_errors`
like other tool errors.

**Return type signatures** widened on `_execute_tool_sync`,
`_execute_tool_async`, `_run_one`, `_arun_one` to include `list[Command
| ToolMessage]`.

### `libs/prebuilt/tests/test_tool_node.py`

New tests covering: valid list returns (top-level terminator, nested
terminator, parent-goto + terminator), regression tests for single
Command/ToolMessage returns, invalid cases (no terminator, multiple
terminators), async parity, integration with mixed list/non-list tool
calls, and `_handle_tool_errors` interaction.

---------

Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2026-04-23 13:37:45 -07:00
8657df80f3 chore: mixup cli formatting (#7585)
Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
2026-04-22 11:29:23 -07:00
24 changed files with 1109 additions and 581 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ jobs:
exit 1
fi
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.0.1" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.0.1; $LANGCHAIN_OPENAI_VERSION"
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION"
exit 1
fi
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
+1 -1
View File
@@ -259,7 +259,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -268,7 +268,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -55,6 +55,19 @@ _warned_unregistered_types: set[tuple[str, str]] = set()
_warned_blocked_types: set[tuple[str, str]] = set()
def _is_safe_json_type(id_list: list[str]) -> bool:
"""Return True if an lc=2 id refers to a type in SAFE_MSGPACK_TYPES.
Safe types bypass the ``allowed_json_modules`` gate so that old "json" format
checkpoints (written before the msgpack migration) can be resumed without
requiring users to configure an explicit allowlist.
"""
if len(id_list) < 2:
return False
module_name = ".".join(id_list[:-1])
return (module_name, id_list[-1]) in _lg_msgpack.SAFE_MSGPACK_TYPES
def _warn_once(
seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object
) -> None:
@@ -164,19 +177,23 @@ class JsonPlusSerializer(SerializerProtocol):
return out
def _reviver(self, value: dict[str, Any]) -> Any:
if self._allowed_json_modules and (
if (
value.get("lc", None) == 2
and value.get("type", None) == "constructor"
and value.get("id", None) is not None
):
try:
return self._revive_lc2(value)
except InvalidModuleError as e:
logger.warning(
"Object %s is not in the deserialization allowlist.\n%s",
value["id"],
e.message,
)
id_list = value["id"]
is_safe = _is_safe_json_type(id_list)
if self._allowed_json_modules or is_safe:
try:
return self._revive_lc2(value)
except InvalidModuleError as e:
if not is_safe:
logger.warning(
"Object %s is not in the deserialization allowlist.\n%s",
value["id"],
e.message,
)
return LC_REVIVER(value)
@@ -224,6 +241,13 @@ class JsonPlusSerializer(SerializerProtocol):
method_display = "<init>"
dotted = ".".join(needed)
# Safe types (the same set already allowed for msgpack deserialization) are
# permitted without an explicit allowlist — they are known-safe LangGraph and
# LangChain types. This restores backwards-compat for old "json" checkpoints
# that pre-date the msgpack migration without reopening the broader security gate.
if _is_safe_json_type(list(needed)):
return
if not self._allowed_json_modules:
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
+51
View File
@@ -333,6 +333,57 @@ def test_serde_jsonplus_bytes() -> None:
assert serde.loads_typed(dumped) == some_bytes
def test_lc2_json_safe_type_revives_without_allowlist() -> None:
"""Old 'json' blobs with lc=2 for safe types must revive without an explicit allowlist.
Regression test for: https://github.com/langchain-ai/langgraph/issues/7498
Threads checkpointed before v1.0.1 (pre-msgpack) stored messages as lc=2 JSON
constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects
rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages.
"""
from langchain_core.messages import AIMessage
serde = JsonPlusSerializer() # default: _allowed_json_modules=None
human_blob = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "human", "HumanMessage"],
"kwargs": {"content": "hello", "type": "human"},
}
ai_blob = {
"lc": 2,
"type": "constructor",
"id": ["langchain_core", "messages", "ai", "AIMessage"],
"kwargs": {"content": "hi there", "type": "ai"},
}
result = serde.loads_typed(("json", json.dumps([human_blob, ai_blob]).encode()))
assert len(result) == 2
assert isinstance(result[0], HumanMessage), (
f"Expected HumanMessage, got {type(result[0])}: {result[0]!r}\n"
"lc=2 JSON blobs for safe types must deserialize without an explicit allowlist"
)
assert result[0].content == "hello"
assert isinstance(result[1], AIMessage)
assert result[1].content == "hi there"
def test_lc2_json_unknown_type_stays_blocked_without_allowlist() -> None:
"""lc=2 JSON blobs for types NOT in SAFE_MSGPACK_TYPES still require an allowlist."""
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["pprint", "pprint"],
"kwargs": {"object": "HELLO"},
}
# No allowlist configured → raw dict returned (not raised, not reconstructed)
result = serde.loads_typed(("json", json.dumps(load).encode()))
assert isinstance(result, dict), "Unknown lc=2 type must stay as raw dict"
assert result.get("lc") == 2
def test_deserde_invalid_module() -> None:
serde = JsonPlusSerializer()
load = {
+1 -1
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.23"
__version__ = "0.4.24"
+124
View File
@@ -0,0 +1,124 @@
"""Shared ignore-file handling for local source filtering."""
import pathlib
from dataclasses import dataclass
import pathspec
_ALWAYS_EXCLUDE = [
"__pycache__/",
".git/",
".venv/",
"venv/",
"node_modules/",
".tox/",
".mypy_cache/",
]
_ALWAYS_EXCLUDE_NAMES = frozenset(
pattern.rstrip("/").split("/")[-1] for pattern in _ALWAYS_EXCLUDE
)
_GLOB_CHARS = frozenset("*?[")
@dataclass(frozen=True, slots=True)
class _NegatedDockerignoreHints:
exact_dirs: frozenset[pathlib.PurePosixPath] = frozenset()
wildcard_prefixes: frozenset[pathlib.PurePosixPath] = frozenset()
recurse_all: bool = False
def requires_dir_walk(self, path: pathlib.PurePosixPath) -> bool:
if self.recurse_all or path in self.exact_dirs:
return True
return any(
path == prefix or path in prefix.parents or prefix in path.parents
for prefix in self.wildcard_prefixes
)
def _build_ignore_spec(
directory: pathlib.Path, *, include_gitignore: bool = True
) -> pathspec.PathSpec:
"""Build a PathSpec combining built-in exclusions with ignore files.
Always excludes common non-source directories (`_ALWAYS_EXCLUDE`). On top
of that, patterns from `.dockerignore` are merged in. `.gitignore` patterns
are optional because some callers need Docker build-context semantics,
while archive creation wants both files.
"""
lines: list[str] = list(_ALWAYS_EXCLUDE)
ignore_files = [".dockerignore"]
if include_gitignore:
ignore_files.append(".gitignore")
for name in ignore_files:
ignore_file = directory / name
if ignore_file.is_file():
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
def _is_always_excluded(path: pathlib.PurePosixPath, *, is_dir: bool) -> bool:
"""Whether `path` lives inside a built-in excluded directory."""
parent_parts = path.parts if is_dir else path.parts[:-1]
return any(part in _ALWAYS_EXCLUDE_NAMES for part in parent_parts)
def _build_dockerignore_negation_hints(
directory: pathlib.Path,
) -> _NegatedDockerignoreHints:
"""Summarize which ignored directories must still be traversed.
Most negations only require walking a small, concrete chain of parent
directories (for example `!assets/keep.txt` requires entering `assets/`).
Broader glob negations may force a wider walk.
"""
ignore_file = directory / ".dockerignore"
if not ignore_file.is_file():
return _NegatedDockerignoreHints()
exact_dirs: set[pathlib.PurePosixPath] = set()
wildcard_prefixes: set[pathlib.PurePosixPath] = set()
recurse_all = False
for raw_line in ignore_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("\\!"):
continue
if line.startswith("\\#"):
line = line[1:]
if not line.startswith("!"):
continue
pattern = line[1:].lstrip("/")
while pattern.startswith("./"):
pattern = pattern[2:]
pattern = pattern.rstrip("/")
parts = [part for part in pattern.split("/") if part and part != "."]
if not parts:
recurse_all = True
continue
wildcard_index = next(
(
idx
for idx, part in enumerate(parts)
if any(char in part for char in _GLOB_CHARS)
),
None,
)
if wildcard_index is not None:
literal_parts = parts[:wildcard_index]
if not literal_parts:
recurse_all = True
continue
wildcard_prefixes.add(pathlib.PurePosixPath(*literal_parts))
continue
parent_parts = parts[:-1]
for idx in range(1, len(parent_parts) + 1):
exact_dirs.add(pathlib.PurePosixPath(*parent_parts[:idx]))
return _NegatedDockerignoreHints(
exact_dirs=frozenset(exact_dirs),
wildcard_prefixes=frozenset(wildcard_prefixes),
recurse_all=recurse_all,
)
+1 -24
View File
@@ -9,35 +9,12 @@ from contextlib import contextmanager
import click
import pathspec
from langgraph_cli._ignore import _build_ignore_spec
from langgraph_cli.config import Config, _assemble_local_deps
_WARN_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_SIZE = 200 * 1024 * 1024 # 200 MB
_ALWAYS_EXCLUDE = [
"__pycache__/",
".git/",
".venv/",
"venv/",
"node_modules/",
".tox/",
".mypy_cache/",
]
def _build_ignore_spec(directory: pathlib.Path) -> pathspec.PathSpec:
"""Build a PathSpec combining built-in exclusions with .dockerignore and .gitignore.
Always excludes common non-source directories (_ALWAYS_EXCLUDE). On top of
that, patterns from .dockerignore and .gitignore (if present) are merged in.
"""
lines: list[str] = list(_ALWAYS_EXCLUDE)
for name in (".dockerignore", ".gitignore"):
ignore_file = directory / name
if ignore_file.is_file():
lines.extend(ignore_file.read_text(encoding="utf-8").splitlines())
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
"""Strip symlinks, hardlinks, and traversal paths from archive."""
+50 -11
View File
@@ -10,7 +10,13 @@ except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10.
import tomli as tomllib
import click
import pathspec
from langgraph_cli._ignore import (
_build_dockerignore_negation_hints,
_build_ignore_spec,
_is_always_excluded,
)
from langgraph_cli.schemas import Config
@@ -440,16 +446,32 @@ def _container_root_for_uv_lock_package(
def _uv_lock_package_copy_items(
package: UvLockPackage, plan: UvLockPlan
package: UvLockPackage,
plan: UvLockPlan,
ignore_spec: pathspec.PathSpec,
) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
# Skip entries that .dockerignore / built-in exclusions would strip from
# the build context. Emitting `ADD <path>` for a file that Docker has
# filtered out causes the build to fail with
# "failed to compute cache key: <path> not found".
if package.root != plan.project_root:
relative_root = pathlib.PurePosixPath(
*package.root.relative_to(plan.project_root).parts
)
if _is_always_excluded(relative_root, is_dir=True) or ignore_spec.match_file(
f"{relative_root.as_posix()}/"
):
raise click.UsageError(
f"Workspace member '{package.name}' at {relative_root} is "
"excluded from the Docker build context, but uv.lock requires "
"it to be copied into the build context. Remove the matching "
"pattern or drop the member from [tool.uv.workspace].members."
)
return ((relative_root, plan.container_roots[package.root]),)
root_container = plan.container_roots[package.root]
workspace_member_roots = plan.all_workspace_roots - {plan.project_root}
negated_dockerignore_hints = _build_dockerignore_negation_hints(plan.project_root)
def iter_entries(
current_dir: pathlib.Path,
@@ -461,18 +483,32 @@ def _uv_lock_package_copy_items(
# and excluded entirely otherwise.
continue
descendant_member_roots = [
ws_root
for ws_root in workspace_member_roots
if child in ws_root.parents
]
if child.is_dir() and descendant_member_roots:
entries.extend(iter_entries(child))
continue
relative_child = pathlib.PurePosixPath(
*child.relative_to(plan.project_root).parts
)
is_dir = child.is_dir()
if _is_always_excluded(relative_child, is_dir=is_dir):
continue
ignored = ignore_spec.match_file(
f"{relative_child.as_posix()}/" if is_dir else relative_child.as_posix()
)
is_workspace_parent = is_dir and any(
child in ws_root.parents for ws_root in workspace_member_roots
)
if is_workspace_parent:
entries.extend(iter_entries(child))
continue
if (
is_dir
and ignored
and negated_dockerignore_hints.requires_dir_walk(relative_child)
):
entries.extend(iter_entries(child))
continue
if ignored:
continue
entries.append(
(relative_child, root_container.joinpath(*relative_child.parts))
)
@@ -956,10 +992,13 @@ def python_config_to_docker_uv_lock(
docker_plan.add_raw("# -- End of uv.lock dependencies install --")
docker_plan.add_blank()
ignore_spec = _build_ignore_spec(plan.project_root, include_gitignore=False)
for package in plan.install_order:
package_label = package.root.relative_to(plan.project_root).as_posix() or "."
docker_plan.add_raw(f"# -- Adding workspace package {package_label} --")
for source, destination in _uv_lock_package_copy_items(package, plan):
for source, destination in _uv_lock_package_copy_items(
package, plan, ignore_spec
):
docker_plan.add_raw(copy_from_project_root(source, destination.as_posix()))
docker_plan.add_instruction(
"WORKDIR", plan.container_roots[package.root].as_posix()
@@ -99,6 +99,13 @@ class TestBuildIgnoreSpec:
assert spec.match_file("app.log")
assert spec.match_file("mod.pyc")
def test_can_skip_gitignore(self, tmp_path):
(tmp_path / ".dockerignore").write_text("*.log\n")
(tmp_path / ".gitignore").write_text("*.pyc\n")
spec = _build_ignore_spec(tmp_path, include_gitignore=False)
assert spec.match_file("app.log")
assert not spec.match_file("mod.pyc")
def test_no_ignore_files_only_builtins(self, tmp_path):
spec = _build_ignore_spec(tmp_path)
assert spec.match_file("__pycache__/")
+359
View File
@@ -4,6 +4,7 @@ import os
import pathlib
import tempfile
import textwrap
from unittest.mock import patch
import click
import pytest
@@ -1855,6 +1856,364 @@ def test_config_to_docker_uv_lock_supports_single_uv_project_root():
assert additional_contexts == {}
def test_config_to_docker_uv_lock_skips_dockerignore_entries():
"""Entries filtered by .dockerignore / built-in excludes must not appear
as ADD lines. Docker fails to compute the cache key for paths that the
build context has stripped."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "README.md").write_text("# hi\n")
# Built-in exclusions — must never appear as ADD lines.
(project_root / ".git").mkdir()
(project_root / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
(project_root / ".venv").mkdir()
(project_root / ".venv" / "pyvenv.cfg").write_text("home = /usr\n")
(project_root / "__pycache__").mkdir()
(project_root / "__pycache__" / "x.cpython-311.pyc").write_bytes(b"\x00")
# .dockerignore excludes .gitignore and a custom path.
(project_root / ".dockerignore").write_text(".gitignore\nsecrets.env\n")
(project_root / ".gitignore").write_text("*.pyc\n")
(project_root / "secrets.env").write_text("TOKEN=abc\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
for excluded in (
"ADD .git ",
"ADD .gitignore ",
"ADD .venv ",
"ADD __pycache__ ",
"ADD secrets.env ",
):
assert excluded not in docker, (
f"{excluded!r} should be filtered out of Dockerfile:\n{docker}"
)
# The .dockerignore itself is still part of the context and should be
# ADDed (Docker needs it at build time, and archive.py includes it).
assert "ADD .dockerignore /deps/workspace/.dockerignore" in docker
assert "ADD src /deps/workspace/src" in docker
assert "ADD README.md /deps/workspace/README.md" in docker
def test_config_to_docker_uv_lock_does_not_apply_gitignore():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "README.md").write_text("# hi\n")
(project_root / ".gitignore").write_text("README.md\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert "ADD README.md /deps/workspace/README.md" in docker
def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace():
"""Multi-member workspace: ignore patterns must filter root-level entries
AND entries encountered while recursing into directories that contain
workspace members (the `descendant_member_roots` branch)."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root, config_path = _write_uv_lock_workspace(
tmpdir_path,
agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
)
root_src = project_root / "src" / "workspace_root"
root_src.mkdir(parents=True)
(root_src / "__init__.py").write_text("__all__ = []\n")
(project_root / "README.md").write_text("workspace root package\n")
# A non-member sibling of the `apps/agent` member that should be
# filtered out via .dockerignore. This exercises the recursion into
# `apps/` where `apps/agent` is kept (it's a member) but its sibling is
# filtered.
(project_root / "apps" / "scratch.txt").write_text("scratch\n")
# A root-level path that .dockerignore excludes.
(project_root / "secrets.env").write_text("TOKEN=abc\n")
(project_root / ".dockerignore").write_text("secrets.env\napps/scratch.txt\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": {"kind": "uv", "root": "../..", "package": "agent"},
}
)
docker, _ = config_to_docker(
config_path, config, base_image="langchain/langgraph-api:0.2.47"
)
assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
assert (
"COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
in docker
)
assert (
"COPY --from=uv-workspace-root .dockerignore /deps/workspace/.dockerignore"
in docker
)
assert "secrets.env" not in docker
assert "apps/scratch.txt" not in docker
# Workspace members themselves are still copied via their own per-member
# COPY line — the sibling filter must not disturb this.
assert (
"COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
in docker
)
def test_config_to_docker_uv_lock_preserves_negated_dockerignore_descendants():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "assets").mkdir()
(project_root / "assets" / "keep.txt").write_text("keep\n")
(project_root / "assets" / "drop.txt").write_text("drop\n")
(project_root / ".dockerignore").write_text("assets/\n!assets/keep.txt\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert "ADD assets /deps/workspace/assets" not in docker
assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
assert "assets/drop.txt" not in docker
def test_config_to_docker_uv_lock_prunes_unrelated_ignored_subtrees():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / "assets").mkdir()
(project_root / "assets" / "keep.txt").write_text("keep\n")
(project_root / "vendor").mkdir()
(project_root / "vendor" / "huge.txt").write_text("large\n")
(project_root / ".dockerignore").write_text(
"vendor/\nassets/\n!assets/keep.txt\n"
)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
original_iterdir = pathlib.Path.iterdir
def guarded_iterdir(self):
if self == project_root / "vendor":
raise AssertionError("should not walk unrelated ignored subtree")
return original_iterdir(self)
with patch.object(
pathlib.Path, "iterdir", autospec=True, side_effect=guarded_iterdir
):
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
assert "vendor/huge.txt" not in docker
def test_config_to_docker_uv_lock_never_reincludes_always_excluded_subtrees():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root = tmpdir_path / "single"
project_root.mkdir()
(project_root / "uv.lock").write_text("# uv lock file\n")
(project_root / "pyproject.toml").write_text(
textwrap.dedent(
"""
[project]
name = "single-app"
version = "0.1.0"
dependencies = ["httpx>=0.28"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
"""
).strip()
+ "\n"
)
(project_root / "langgraph.json").write_text("{}\n")
(project_root / "src").mkdir()
(project_root / "src" / "agent.py").write_text("graph = object()\n")
(project_root / ".venv" / "pkg").mkdir(parents=True)
(project_root / ".venv" / "pkg" / "keep.txt").write_text("keep\n")
(project_root / "node_modules" / "pkg").mkdir(parents=True)
(project_root / "node_modules" / "pkg" / "package.json").write_text("{}\n")
(project_root / ".dockerignore").write_text(
"!.venv/pkg/keep.txt\n!node_modules/pkg/package.json\n"
)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
docker, _ = config_to_docker(
project_root / "langgraph.json",
config,
base_image="langchain/langgraph-api:0.2.47",
)
assert ".venv/pkg/keep.txt" not in docker
assert "node_modules/pkg/package.json" not in docker
assert "ADD src /deps/workspace/src" in docker
def test_config_to_docker_uv_lock_rejects_ignored_workspace_member():
"""A workspace member matched by .dockerignore cannot be copied into the
build context — uv.lock requires it, so fail loudly with a clear message."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
project_root, config_path = _write_uv_lock_workspace(
tmpdir_path,
agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
)
(project_root / ".dockerignore").write_text("libs/shared\n")
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": {"kind": "uv", "root": "../..", "package": "agent"},
"auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
}
)
with pytest.raises(
click.UsageError, match=r"Workspace member 'shared' at libs/shared"
):
config_to_docker(
config_path, config, base_image="langchain/langgraph-api:0.2.47"
)
def test_config_to_docker_uv_lock_rejects_invalid_source_package_type():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
+21 -51
View File
@@ -184,72 +184,39 @@ def add_messages(
```
"""
remove_all_idx = None
# coerce to list
if not isinstance(left, list):
left = [left] # type: ignore[assignment]
if not isinstance(right, list):
right = [right] # type: ignore[assignment]
# Optimization 1: skip conversion + ID assignment on left when it already
# contains fully-resolved BaseMessage objects (the common case after the
# first call, since add_messages always returns list[BaseMessage] with IDs).
left_msgs: list[BaseMessage]
left_seq = cast(list, left)
if (
left_seq
and isinstance(left_seq[0], BaseMessage)
and left_seq[0].id is not None
and not isinstance(left_seq[0], BaseMessageChunk)
):
left_msgs = left_seq
else:
left_msgs = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(left)
]
for m in left_msgs:
if m.id is None:
m.id = str(uuid.uuid4())
# always normalise right — it's fresh external input
right_msgs: list[BaseMessage] = [
# coerce to message
left = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(left)
]
right = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(right)
]
remove_all_idx = None
has_remove = False
for idx, m in enumerate(right_msgs):
# assign missing ids
for m in left:
if m.id is None:
m.id = str(uuid.uuid4())
if isinstance(m, RemoveMessage):
has_remove = True
if m.id == REMOVE_ALL_MESSAGES:
remove_all_idx = idx
for idx, m in enumerate(right):
if m.id is None:
m.id = str(uuid.uuid4())
if isinstance(m, RemoveMessage) and m.id == REMOVE_ALL_MESSAGES:
remove_all_idx = idx
if remove_all_idx is not None:
return right_msgs[remove_all_idx + 1 :]
return right[remove_all_idx + 1 :]
# Optimization 2: pure-append fast path — no removals, no ID overlaps with
# left, and no duplicate IDs within right (all imply a dedup/update is needed).
if not has_remove:
left_ids = {m.id for m in left_msgs}
right_id_set = {m.id for m in right_msgs}
if len(right_id_set) == len(right_msgs) and not (right_id_set & left_ids):
result = left_msgs + right_msgs
if format == "langchain-openai":
return _format_messages(result)
elif format:
msg = (
f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
)
raise ValueError(msg)
return result
# slow path: updates or removals present — full indexed merge
merged = left_msgs.copy()
# merge
merged = left.copy()
merged_by_id = {m.id: i for i, m in enumerate(merged)}
ids_to_remove = set()
for m in right_msgs:
for m in right:
if (existing_idx := merged_by_id.get(m.id)) is not None:
if isinstance(m, RemoveMessage):
ids_to_remove.add(m.id)
@@ -261,6 +228,7 @@ def add_messages(
raise ValueError(
f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
)
merged_by_id[m.id] = len(merged)
merged.append(m)
merged = [m for m in merged if m.id not in ids_to_remove]
@@ -270,6 +238,8 @@ def add_messages(
elif format:
msg = f"Unrecognized {format=}. Expected one of 'langchain-openai', None."
raise ValueError(msg)
else:
pass
return merged
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.1.9"
version = "1.1.10"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -1,290 +0,0 @@
"""Benchmark: add_messages fast-path optimizations.
Both implementations are inlined so the benchmark is self-contained and
immune to import-cache or installed-vs-local confusion.
Run directly:
python tests/test_add_messages_benchmark.py
Or via pytest (correctness only, numbers printed to stdout):
pytest tests/test_add_messages_benchmark.py -s -v
"""
import statistics
import time
import tracemalloc
import uuid
from typing import cast
from langchain_core.messages import (
AIMessage,
BaseMessage,
BaseMessageChunk,
HumanMessage,
RemoveMessage,
convert_to_messages,
message_chunk_to_message,
)
from langgraph.graph.message import REMOVE_ALL_MESSAGES
# ── original implementation (pre-optimisation) ────────────────────────────────
def _add_messages_original(left, right):
remove_all_idx = None
if not isinstance(left, list):
left = [left]
if not isinstance(right, list):
right = [right]
left = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(left)
]
right = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(right)
]
for m in left:
if m.id is None:
m.id = str(uuid.uuid4())
for idx, m in enumerate(right):
if m.id is None:
m.id = str(uuid.uuid4())
if isinstance(m, RemoveMessage) and m.id == REMOVE_ALL_MESSAGES:
remove_all_idx = idx
if remove_all_idx is not None:
return right[remove_all_idx + 1 :]
merged = left.copy()
merged_by_id = {m.id: i for i, m in enumerate(merged)}
ids_to_remove = set()
for m in right:
if (existing_idx := merged_by_id.get(m.id)) is not None:
if isinstance(m, RemoveMessage):
ids_to_remove.add(m.id)
else:
ids_to_remove.discard(m.id)
merged[existing_idx] = m
else:
if isinstance(m, RemoveMessage):
raise ValueError(
f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
)
merged_by_id[m.id] = len(merged)
merged.append(m)
return [m for m in merged if m.id not in ids_to_remove]
# ── optimised implementation ──────────────────────────────────────────────────
def _add_messages_optimized(left, right):
if not isinstance(left, list):
left = [left]
if not isinstance(right, list):
right = [right]
# Optimisation 1: skip conversion + ID assignment on left when it already
# contains fully-resolved BaseMessage objects (the common case after the
# first call, since add_messages always returns list[BaseMessage] with IDs).
if (
left
and isinstance(left[0], BaseMessage)
and not isinstance(left[0], BaseMessageChunk)
):
left = cast(list[BaseMessage], left)
else:
left = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(left)
]
for m in left:
if m.id is None:
m.id = str(uuid.uuid4())
# always normalise right — it's fresh external input
right = [
message_chunk_to_message(cast(BaseMessageChunk, m))
for m in convert_to_messages(right)
]
remove_all_idx = None
has_remove = False
for idx, m in enumerate(right):
if m.id is None:
m.id = str(uuid.uuid4())
if isinstance(m, RemoveMessage):
has_remove = True
if m.id == REMOVE_ALL_MESSAGES:
remove_all_idx = idx
if remove_all_idx is not None:
return right[remove_all_idx + 1 :]
# Optimisation 2: pure-append fast path — no removals and no ID overlaps.
# Builds one set over left instead of copying left + building a full dict.
if not has_remove:
left_ids = {m.id for m in left}
if not any(m.id in left_ids for m in right):
return left + right
# slow path: updates or removals present — full indexed merge
merged = left.copy()
merged_by_id = {m.id: i for i, m in enumerate(merged)}
ids_to_remove = set()
for m in right:
if (existing_idx := merged_by_id.get(m.id)) is not None:
if isinstance(m, RemoveMessage):
ids_to_remove.add(m.id)
else:
ids_to_remove.discard(m.id)
merged[existing_idx] = m
else:
if isinstance(m, RemoveMessage):
raise ValueError(
f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
)
merged_by_id[m.id] = len(merged)
merged.append(m)
return [m for m in merged if m.id not in ids_to_remove]
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_messages(n: int) -> list[BaseMessage]:
return [
(HumanMessage if i % 2 == 0 else AIMessage)(
content=f"message {i}", id=str(uuid.uuid4())
)
for i in range(n)
]
def _bench_time(fn, left, right, *, iters: int = 2_000) -> float:
"""Return median latency in microseconds."""
for _ in range(100):
fn(list(left), list(right))
times = []
for _ in range(iters):
left_copy, right_copy = list(left), list(right)
t0 = time.perf_counter()
fn(left_copy, right_copy)
times.append(time.perf_counter() - t0)
return statistics.median(times) * 1e6
def _bench_memory(fn, left, right) -> int:
"""Return peak memory allocated during a single call (bytes)."""
# one warm-up so any lazy init is excluded
fn(list(left), list(right))
left_copy, right_copy = list(left), list(right)
tracemalloc.start()
tracemalloc.clear_traces()
fn(left_copy, right_copy)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return peak
# ── scenarios ─────────────────────────────────────────────────────────────────
SCENARIOS = [
("pure append 1 → 1 msg", 1, 1, "append"),
("pure append 10 → 1 msg", 10, 1, "append"),
("pure append 100 → 1 msg", 100, 1, "append"),
("pure append 1000 → 1 msg", 1000, 1, "append"),
("pure append 1000 → 5 msgs", 1000, 5, "append"),
("update existing 100 → 1 msg", 100, 1, "update"),
("remove message 100 → 1 msg", 100, 1, "remove"),
]
def _make_inputs(n_left, n_right, mode):
left = _make_messages(n_left)
right = _make_messages(n_right)
if mode == "update":
right[0] = AIMessage(content="updated", id=left[0].id)
elif mode == "remove":
right = [RemoveMessage(id=left[0].id)]
return left, right
# ── main output ───────────────────────────────────────────────────────────────
COL = 36
def run_benchmarks() -> None:
print()
print("=" * 88)
print("add_messages benchmark — time (µs, median of 2 000 iterations)")
print("=" * 88)
print(f"{'Scenario':<{COL}} {'Original':>10} {'Optimized':>11} {'Speedup':>8}")
print("-" * 88)
for label, n_left, n_right, mode in SCENARIOS:
left, right = _make_inputs(n_left, n_right, mode)
t_orig = _bench_time(_add_messages_original, left, right)
t_opt = _bench_time(_add_messages_optimized, left, right)
print(f"{label:<{COL}} {t_orig:>10.2f} {t_opt:>11.2f} {t_orig / t_opt:>7.2f}x")
print()
print("=" * 88)
print("add_messages benchmark — peak memory allocated per call (bytes)")
print("=" * 88)
print(f"{'Scenario':<{COL}} {'Original':>10} {'Optimized':>11} {'Reduction':>10}")
print("-" * 88)
for label, n_left, n_right, mode in SCENARIOS:
left, right = _make_inputs(n_left, n_right, mode)
m_orig = _bench_memory(_add_messages_original, left, right)
m_opt = _bench_memory(_add_messages_optimized, left, right)
reduction = (1 - m_opt / m_orig) * 100 if m_orig else 0.0
print(f"{label:<{COL}} {m_orig:>10,} {m_opt:>11,} {reduction:>9.1f}%")
print()
print("=" * 88)
print("Simulated long thread — 200 steps × 2 msgs appended per step")
print("=" * 88)
for name, fn in [
("original", _add_messages_original),
("optimized", _add_messages_optimized),
]:
state: list = []
t0 = time.perf_counter()
for step in range(200):
new_msgs = [
HumanMessage(content=f"step {step} human", id=str(uuid.uuid4())),
AIMessage(content=f"step {step} ai", id=str(uuid.uuid4())),
]
state = fn(state, new_msgs)
elapsed = (time.perf_counter() - t0) * 1_000
print(f" {name:<12} {elapsed:.2f} ms ({len(state)} messages)")
print()
# ── pytest entry-points ───────────────────────────────────────────────────────
def test_add_messages_correctness():
"""Optimised implementation must match original output for every scenario."""
for label, n_left, n_right, mode in SCENARIOS:
left, right = _make_inputs(n_left, n_right, mode)
expected = _add_messages_original(list(left), list(right))
actual = _add_messages_optimized(list(left), list(right))
assert len(actual) == len(expected), f"[{label}] length mismatch"
for a, b in zip(actual, expected):
assert type(a) is type(b), f"[{label}] type mismatch"
assert a.id == b.id, f"[{label}] id mismatch"
assert a.content == b.content, f"[{label}] content mismatch"
def test_add_messages_benchmark(capsys):
run_benchmarks()
out = capsys.readouterr().out
assert "Speedup" in out
assert "Optimized" in out
if __name__ == "__main__":
run_benchmarks()
-118
View File
@@ -5,7 +5,6 @@ import langchain_core
import pytest
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
AnyMessage,
HumanMessage,
RemoveMessage,
@@ -339,123 +338,6 @@ def test_remove_all_messages():
]
def test_fast_path_preserves_format_openai():
"""Pure-append fast path must still apply the `langchain-openai` formatter."""
left = [HumanMessage(content="prior", id="1")]
right = [
AIMessage(
content=[
{
"type": "tool_use",
"name": "foo",
"input": {"bar": "baz"},
"id": "t1",
}
],
id="2",
)
]
result = add_messages(left, right, format="langchain-openai")
assert isinstance(result[0], HumanMessage)
assert result[0].content == "prior"
assert isinstance(result[1], AIMessage)
# formatter collapses the tool_use content block into `tool_calls`
assert result[1].content == ""
assert len(result[1].tool_calls) == 1
assert result[1].tool_calls[0]["name"] == "foo"
assert result[1].tool_calls[0]["args"] == {"bar": "baz"}
assert result[1].tool_calls[0]["id"] == "t1"
def test_fast_path_rejects_invalid_format():
"""Pure-append fast path must validate the `format` arg like the slow path."""
left = [HumanMessage(content="prior", id="1")]
right = [AIMessage(content="new", id="2")]
with pytest.raises(ValueError, match="Unrecognized format="):
add_messages(left, right, format="bogus") # type: ignore[arg-type]
def test_left_starting_with_chunk_is_normalized():
"""Opt-1 guard: a `BaseMessageChunk` at left[0] must trigger full conversion."""
chunk = AIMessageChunk(content="chunk", id="c1")
result = add_messages([chunk], [HumanMessage(content="h", id="h1")])
assert len(result) == 2
# chunk must be converted to a non-chunk message
assert type(result[0]).__name__ == "AIMessage"
assert result[0].id == "c1"
assert result[1].id == "h1"
def test_left_as_dicts_is_normalized():
"""Opt-1 guard: dicts at left[0] must trigger full conversion."""
left = [{"role": "user", "content": "hi", "id": "d1"}]
right = [AIMessage(content="reply", id="a1")]
result = add_messages(left, right)
assert len(result) == 2
assert isinstance(result[0], HumanMessage)
assert result[0].id == "d1"
assert result[0].content == "hi"
def test_left_as_tuples_is_normalized():
"""Opt-1 guard: tuple-form messages must trigger full conversion."""
left = [("user", "hi")]
right = [AIMessage(content="reply", id="a1")]
result = add_messages(left, right)
assert len(result) == 2
assert isinstance(result[0], HumanMessage)
# id is auto-assigned
assert isinstance(result[0].id, str) and UUID(result[0].id, version=4)
def test_left_first_msg_missing_id_is_normalized():
"""Opt-1 guard: a BaseMessage without an id at left[0] falls to the else branch."""
left = [HumanMessage(content="hi")] # no id
right = [AIMessage(content="reply", id="a1")]
result = add_messages(left, right)
assert len(result) == 2
# left's id must have been auto-assigned
assert isinstance(result[0].id, str) and UUID(result[0].id, version=4)
def test_duplicate_ids_in_right_with_nonempty_left():
"""Opt-2 guard: intra-right duplicate ids must take slow path (dedup kept)."""
left = [HumanMessage(content="prior", id="1")]
right = [
AIMessage(content="first", id="2"),
AIMessage(content="second", id="2"),
]
result = add_messages(left, right)
assert len(result) == 2
assert result[0].id == "1"
assert result[1].id == "2"
assert result[1].content == "second"
def test_right_with_none_ids_pure_append():
"""Fast path still correct when right entries start with id=None (fresh uuids assigned)."""
left = [HumanMessage(content="prior", id="1")]
right = [AIMessage(content="a"), AIMessage(content="b")]
result = add_messages(left, right)
assert len(result) == 3
assert result[0].id == "1"
for m in result[1:]:
assert isinstance(m.id, str) and UUID(m.id, version=4)
# fresh uuids must be distinct
assert result[1].id != result[2].id
def test_fast_path_returns_fresh_list():
"""Fast path must return a new list object (not mutate or alias left)."""
left = [HumanMessage(content="prior", id="1")]
right = [AIMessage(content="new", id="2")]
result = add_messages(left, right)
assert result is not left
# left must be untouched
assert len(left) == 1
assert left[0].id == "1"
def test_push_messages_in_graph():
class MessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
+13 -13
View File
@@ -1348,7 +1348,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.0"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -1360,14 +1360,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
]
[[package]]
name = "langgraph"
version = "1.1.9"
version = "1.1.10"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1548,7 +1548,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -1742,7 +1742,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.10"
version = "1.0.11"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1751,7 +1751,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
@@ -2140,7 +2140,7 @@ wheels = [
[[package]]
name = "nbconvert"
version = "7.17.0"
version = "7.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
@@ -2158,9 +2158,9 @@ dependencies = [
{ name = "pygments" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" }
sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
]
[[package]]
@@ -3018,11 +3018,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.1"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
+142 -41
View File
@@ -82,6 +82,7 @@ from langchain_core.tools.base import (
_is_injected_arg_type,
get_all_basemodel_annotations,
)
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
from langgraph._internal._runnable import RunnableCallable
from langgraph.errors import GraphBubbleUp
from langgraph.graph.message import REMOVE_ALL_MESSAGES
@@ -800,7 +801,7 @@ class ToolNode(RunnableCallable):
# Construct ToolRuntime instances at the top level for each tool call
tool_runtimes = []
for call, cfg in zip(tool_calls, config_list, strict=False):
state = self._extract_state(input)
state = self._extract_state(input, cfg)
tool_runtime = ToolRuntime(
state=state,
tool_call_id=call["id"],
@@ -835,7 +836,7 @@ class ToolNode(RunnableCallable):
# Construct ToolRuntime instances at the top level for each tool call
tool_runtimes = []
for call, cfg in zip(tool_calls, config_list, strict=False):
state = self._extract_state(input)
state = self._extract_state(input, cfg)
tool_runtime = ToolRuntime(
state=state,
tool_call_id=call["id"],
@@ -859,14 +860,30 @@ class ToolNode(RunnableCallable):
def _combine_tool_outputs(
self,
outputs: list[ToolMessage | Command],
outputs: list[ToolMessage | Command | list[ToolMessage | Command]],
input_type: Literal["list", "dict", "tool_calls"],
) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]:
# Flatten list entries from tools that returned multiple items
flat_outputs: list[ToolMessage | Command]
if any(isinstance(output, list) for output in outputs):
flat_outputs = []
for output in outputs:
if isinstance(output, list):
flat_outputs.extend(output)
else:
flat_outputs.append(output)
else:
flat_outputs = cast("list[ToolMessage | Command]", outputs)
# preserve existing behavior for non-command tool outputs for backwards
# compatibility
if not any(isinstance(output, Command) for output in outputs):
if not any(isinstance(output, Command) for output in flat_outputs):
# TypedDict, pydantic, dataclass, etc. should all be able to load from dict
return outputs if input_type == "list" else {self._messages_key: outputs}
return (
flat_outputs
if input_type == "list"
else {self._messages_key: flat_outputs}
)
# LangGraph will automatically handle list of Command and non-command node
# updates
@@ -876,7 +893,7 @@ class ToolNode(RunnableCallable):
# combine all parent commands with goto into a single parent command
parent_command: Command | None = None
for output in outputs:
for output in flat_outputs:
if isinstance(output, Command):
if (
output.graph is Command.PARENT
@@ -906,7 +923,7 @@ class ToolNode(RunnableCallable):
request: ToolCallRequest,
input_type: Literal["list", "dict", "tool_calls"],
config: RunnableConfig,
) -> ToolMessage | Command:
) -> ToolMessage | Command | list[Command | ToolMessage]:
"""Execute tool call with configured error handling.
Args:
@@ -915,7 +932,7 @@ class ToolNode(RunnableCallable):
config: Runnable configuration.
Returns:
ToolMessage or Command.
ToolMessage, Command, or list of Command/ToolMessage.
Raises:
Exception: If tool fails and handle_tool_errors is False.
@@ -947,6 +964,11 @@ class ToolNode(RunnableCallable):
call["name"], exc, call["args"], filtered_errors
) from exc
# Inside try so validation errors route through _handle_tool_errors
return self._normalize_tool_response(
response, request.tool_call, input_type
)
# GraphInterrupt is a special exception that will always be raised.
# It can be triggered in the following scenarios,
# Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation
@@ -988,23 +1010,12 @@ class ToolNode(RunnableCallable):
status="error",
)
# Process successful response
if isinstance(response, Command):
# Validate Command before returning to handler
return self._validate_tool_command(response, request.tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
return response
msg = f"Tool {call['name']} returned unexpected type: {type(response)}"
raise TypeError(msg)
def _run_one(
self,
call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
tool_runtime: ToolRuntime,
) -> ToolMessage | Command:
) -> ToolMessage | Command | list[Command | ToolMessage]:
"""Execute single tool call with wrap_tool_call wrapper if configured.
Args:
@@ -1059,7 +1070,7 @@ class ToolNode(RunnableCallable):
request: ToolCallRequest,
input_type: Literal["list", "dict", "tool_calls"],
config: RunnableConfig,
) -> ToolMessage | Command:
) -> ToolMessage | Command | list[Command | ToolMessage]:
"""Execute tool call asynchronously with configured error handling.
Args:
@@ -1068,7 +1079,7 @@ class ToolNode(RunnableCallable):
config: Runnable configuration.
Returns:
ToolMessage or Command.
ToolMessage, Command, or list of Command/ToolMessage.
Raises:
Exception: If tool fails and handle_tool_errors is False.
@@ -1100,6 +1111,11 @@ class ToolNode(RunnableCallable):
call["name"], exc, call["args"], filtered_errors
) from exc
# Inside try so validation errors route through _handle_tool_errors
return self._normalize_tool_response(
response, request.tool_call, input_type
)
# GraphInterrupt is a special exception that will always be raised.
# It can be triggered in the following scenarios,
# Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation
@@ -1141,23 +1157,12 @@ class ToolNode(RunnableCallable):
status="error",
)
# Process successful response
if isinstance(response, Command):
# Validate Command before returning to handler
return self._validate_tool_command(response, request.tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
return response
msg = f"Tool {call['name']} returned unexpected type: {type(response)}"
raise TypeError(msg)
async def _arun_one(
self,
call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
tool_runtime: ToolRuntime,
) -> ToolMessage | Command:
) -> ToolMessage | Command | list[Command | ToolMessage]:
"""Execute single tool call asynchronously with awrap_tool_call wrapper if configured.
Args:
@@ -1273,18 +1278,37 @@ class ToolNode(RunnableCallable):
return None
def _extract_state(
self, input: list[AnyMessage] | dict[str, Any] | BaseModel
self,
input: list[AnyMessage] | dict[str, Any] | BaseModel,
config: RunnableConfig,
) -> list[AnyMessage] | dict[str, Any] | BaseModel:
"""Extract state from input, handling ToolCallWithContext if present.
"""Extract state from input.
Args:
input: The input which may be raw state or ToolCallWithContext.
Three input shapes:
Returns:
The actual state to pass to wrap_tool_call wrappers.
- `ToolCallWithContext` dict — legacy Send payload carrying an inlined
state snapshot; return `input["state"]`.
- list of `ToolCall` dicts — new Send payload with no inlined state;
hydrate state from channels via `CONFIG_KEY_READ`.
- regular graph state (dict/list/BaseModel) — return `input` as-is.
"""
if isinstance(input, dict) and input.get("__type") == "tool_call_with_context":
return input["state"]
if (
isinstance(input, list)
and input
and isinstance(input[-1], dict)
and input[-1].get("type") == "tool_call"
):
read = config.get(CONF, {}).get(CONFIG_KEY_READ)
if read is None:
return {}
# Pregel installs CONFIG_KEY_READ as
# `functools.partial(local_read, scratchpad, channels, managed, task)`.
# Match the previous inlined-state contract by reading channels only;
# managed values have their own injection path (`ToolRuntime.context`).
channels = read.args[1]
return cast("dict[str, Any]", read(list(channels), True))
return input
def _inject_tool_args(
@@ -1404,11 +1428,84 @@ class ToolNode(RunnableCallable):
tool_call_copy["args"] = {**stripped_args, **injected_args}
return tool_call_copy
def _normalize_tool_response(
self,
response: Any,
tool_call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
) -> ToolMessage | Command | list[Command | ToolMessage]:
"""Validate and normalize a tool's raw return value."""
if isinstance(response, Command):
return self._validate_tool_command(response, tool_call, input_type)
if isinstance(response, ToolMessage):
response.content = cast("str | list", msg_content_output(response.content))
return response
if isinstance(response, list):
if all(isinstance(r, (Command, ToolMessage)) for r in response):
return self._validate_tool_command_list(response, tool_call, input_type)
msg = (
f"Tool {tool_call['name']} returned a list with invalid element "
"types: expected all Command or ToolMessage"
)
raise TypeError(msg)
msg = f"Tool {tool_call['name']} returned unexpected type: {type(response)}"
raise TypeError(msg)
def _validate_tool_command_list(
self,
response: list[Command | ToolMessage],
tool_call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
) -> list[Command | ToolMessage]:
"""Validate a list of Command/ToolMessage returned by a single tool call.
Requires exactly one terminating ToolMessage (matching the outer tool_call_id)
across the list — either as a top-level element or nested in a
Command.update["messages"].
"""
expected_id = tool_call["id"]
terminator_count = 0
for item in response:
if isinstance(item, ToolMessage):
if item.tool_call_id == expected_id:
terminator_count += 1
elif isinstance(item, Command) and isinstance(item.update, dict):
for msg in item.update.get(self._messages_key, []):
if isinstance(msg, ToolMessage) and msg.tool_call_id == expected_id:
terminator_count += 1
if terminator_count != 1:
msg = (
f"Tool {tool_call['name']} returned a list with "
f"{terminator_count} messages bound to tool_call_id "
f"{expected_id!r}; expected exactly one terminating ToolMessage."
)
raise ValueError(msg)
# Per-Command normalization still runs, but the list-level count above
# already guarantees exactly one terminator, so individual Commands may
# lack one.
validated: list[Command | ToolMessage] = []
for item in response:
if isinstance(item, Command):
validated.append(
self._validate_tool_command(
item, tool_call, input_type, require_terminator=False
)
)
else:
item.content = cast("str | list", msg_content_output(item.content))
validated.append(item)
return validated
def _validate_tool_command(
self,
command: Command,
call: ToolCall,
input_type: Literal["list", "dict", "tool_calls"],
*,
require_terminator: bool = True,
) -> Command:
if isinstance(command.update, dict):
# input type is dict when ToolNode is invoked with a dict input
@@ -1458,7 +1555,11 @@ class ToolNode(RunnableCallable):
# validate that we always have a ToolMessage matching the tool call in
# Command.update if command is sent to the CURRENT graph
if updated_command.graph is None and not has_matching_tool_message:
if (
require_terminator
and updated_command.graph is None
and not has_matching_tool_message
):
example_update = (
'`Command(update={"messages": '
'[ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`'
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.0.10"
version = "1.0.11"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -25,7 +25,7 @@ classifiers = [
]
dependencies = [
"langgraph-checkpoint>=2.1.0,<5.0.0",
"langchain-core>=1.0.0",
"langchain-core>=1.3.1",
]
[project.urls]
+92
View File
@@ -1320,6 +1320,98 @@ async def test_state_extraction_with_tool_call_with_context_async() -> None:
assert "tool_call" not in state_seen[0]
def _config_with_channel_read(
channel_values: dict[str, object],
store: BaseStore | None = None,
) -> RunnableConfig:
"""Build a config that mimics `CONFIG_KEY_READ` as Pregel installs it.
Pregel always installs a `functools.partial(local_read, scratchpad,
channels, managed, task)`, and `ToolNode` introspects that partial to
learn channel names. The stub matches the shape: partial whose second and
third positional args are `channels` and `managed` mappings.
"""
import functools
channels_stub = {k: None for k in channel_values}
managed_stub: dict[str, object] = {}
# Shape matches pregel's real partial:
# functools.partial(local_read, scratchpad, channels, managed, task)
def _read(scratchpad, channels, managed, task, select, fresh): # noqa: ARG001
if isinstance(select, str):
return channel_values[select]
return {k: channel_values[k] for k in select if k in channel_values}
read = functools.partial(_read, None, channels_stub, managed_stub, None)
cfg = _create_config_with_runtime(store)
cfg["configurable"]["__pregel_read"] = read
return cfg
def test_list_form_send_hydrates_state_from_channel_read() -> None:
"""Send('tools', [tool_call]) with no inlined state should hydrate
ToolRuntime.state from CONFIG_KEY_READ (full state read)."""
state_seen = []
def state_inspector_handler(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
state_seen.append(request.state)
return execute(request)
channel_values = {
"messages": [AIMessage("from channels")],
"files": {"/a.md": "body"},
}
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
tool_call: ToolCall = {
"name": "add",
"args": {"a": 1, "b": 2},
"id": "call_1",
"type": "tool_call",
}
tool_node.invoke([tool_call], config=_config_with_channel_read(channel_values))
assert len(state_seen) == 1
got = state_seen[0]
assert got == channel_values
assert "messages" in got and "files" in got
async def test_list_form_send_hydrates_state_async() -> None:
state_seen = []
def state_inspector_handler(
request: ToolCallRequest,
execute: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
state_seen.append(request.state)
return execute(request)
channel_values = {"messages": [AIMessage("from channels")], "files": {}}
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
tool_call: ToolCall = {
"name": "add",
"args": {"a": 1, "b": 2},
"id": "call_1",
"type": "tool_call",
}
await tool_node.ainvoke(
[tool_call], config=_config_with_channel_read(channel_values)
)
assert len(state_seen) == 1
assert state_seen[0] == channel_values
def test_tool_call_request_is_frozen() -> None:
"""Test that ToolCallRequest raises deprecation warnings on direct attribute reassignment."""
tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}
+192
View File
@@ -2223,3 +2223,195 @@ def test_tool_node_injected_state_overwrites_llm_value() -> None:
)
tool_message = result["messages"][-1]
assert tool_message.content == "PUBLIC_DATA"
class _ReturningTool(BaseTool):
"""A tool that returns a configured value verbatim."""
name: str = "list_tool"
description: str = "Returns a configured value"
return_value: Any = None
def _run(self, **kwargs: Any) -> Any:
return self.return_value
async def _arun(self, **kwargs: Any) -> Any:
return self.return_value
def _list_tool_call(outer_id: str = "call-1") -> dict[str, Any]:
return {"name": "list_tool", "args": {}, "id": outer_id, "type": "tool_call"}
def _invoke_returning(
return_value: Any,
*,
outer_id: str = "call-1",
handle_tool_errors: bool = True,
) -> Any:
node = ToolNode(
[_ReturningTool(return_value=return_value)],
handle_tool_errors=handle_tool_errors,
)
return node.invoke(
{"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]},
config=_create_config_with_runtime(),
)
def test_tool_node_list_return_command_and_tool_message() -> None:
"""Valid: tool returns [Command(update={...}), ToolMessage(...)]."""
outer_id = "call-1"
result = _invoke_returning(
[
Command(update={"foo": "bar"}),
ToolMessage(content="done", tool_call_id=outer_id),
]
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 1
assert commands[0].update == {"foo": "bar"}
non_commands = [r for r in result if not isinstance(r, Command)]
assert len(non_commands) == 1
assert isinstance(non_commands[0], dict)
msgs = non_commands[0]["messages"]
assert len(msgs) == 1
assert isinstance(msgs[0], ToolMessage)
assert msgs[0].content == "done"
assert msgs[0].tool_call_id == outer_id
def test_tool_node_list_return_nested_terminator() -> None:
"""Valid: terminator nested inside Command.update['messages']."""
outer_id = "call-1"
result = _invoke_returning(
[
Command(update={"foo": "bar"}),
Command(
update={
"messages": [ToolMessage(content="done", tool_call_id=outer_id)]
}
),
]
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 2
updates = [c.update for c in commands]
assert {"foo": "bar"} in updates
msgs_update = next(u for u in updates if "messages" in (u or {}))
assert any(
isinstance(m, ToolMessage) and m.tool_call_id == outer_id
for m in msgs_update["messages"]
)
def test_tool_node_list_return_parent_goto_with_terminator() -> None:
"""Valid: [Command(graph=PARENT, goto=[Send(...)]), ToolMessage(...)]."""
outer_id = "call-1"
result = _invoke_returning(
[
Command(graph=Command.PARENT, goto=[Send("child", {})]),
ToolMessage(content="ok", tool_call_id=outer_id),
]
)
assert isinstance(result, list)
parent_cmds = [
r for r in result if isinstance(r, Command) and r.graph is Command.PARENT
]
assert len(parent_cmds) == 1
assert isinstance(parent_cmds[0].goto, list)
assert any(isinstance(s, Send) for s in parent_cmds[0].goto)
non_commands = [r for r in result if not isinstance(r, Command)]
assert len(non_commands) == 1
def test_tool_node_list_return_no_terminator_raises() -> None:
"""Invalid: list with no terminating ToolMessage."""
with pytest.raises(ValueError, match="0 messages bound to tool_call_id"):
_invoke_returning([Command(update={"foo": "bar"})], handle_tool_errors=False)
def test_tool_node_list_return_multiple_terminators_raises() -> None:
"""Invalid: list with two terminating ToolMessages."""
outer_id = "call-1"
with pytest.raises(ValueError, match="2 messages bound to tool_call_id"):
_invoke_returning(
[
ToolMessage(content="a", tool_call_id=outer_id),
ToolMessage(content="b", tool_call_id=outer_id),
],
handle_tool_errors=False,
)
def test_tool_node_list_return_validation_error_handled() -> None:
"""handle_tool_errors=True converts validation errors to an error ToolMessage."""
result = _invoke_returning([Command(update={"foo": "bar"})])
assert isinstance(result, dict)
msg = result["messages"][0]
assert isinstance(msg, ToolMessage)
assert msg.status == "error"
assert "0 messages bound to tool_call_id" in msg.content
async def test_tool_node_list_return_async_smoke() -> None:
"""Async path parallels sync for the happy case."""
outer_id = "call-1"
node = ToolNode(
[
_ReturningTool(
return_value=[
Command(update={"foo": "bar"}),
ToolMessage(content="done", tool_call_id=outer_id),
]
)
]
)
result = await node.ainvoke(
{"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]},
config=_create_config_with_runtime(),
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 1 and commands[0].update == {"foo": "bar"}
def test_tool_node_list_return_mixed_with_regular_tool() -> None:
"""List-returning tool and a regular tool dispatched from the same AIMessage."""
list_tool_id = "call-list"
regular_tool_id = "call-regular"
list_tool = _ReturningTool(
return_value=[
Command(update={"foo": "bar"}),
ToolMessage(content="list done", tool_call_id=list_tool_id),
]
)
def regular_tool(x: int) -> str:
"""A normal tool."""
return f"regular: {x}"
tool_calls = [
{"name": "list_tool", "args": {}, "id": list_tool_id, "type": "tool_call"},
{
"name": "regular_tool",
"args": {"x": 7},
"id": regular_tool_id,
"type": "tool_call",
},
]
node = ToolNode([list_tool, regular_tool])
result = node.invoke(
{"messages": [AIMessage("", tool_calls=tool_calls)]},
config=_create_config_with_runtime(),
)
assert isinstance(result, list)
commands = [r for r in result if isinstance(r, Command)]
assert len(commands) == 1
assert commands[0].update == {"foo": "bar"}
all_msgs = [m for r in result if isinstance(r, dict) for m in r["messages"]]
tool_call_ids = {m.tool_call_id for m in all_msgs}
assert list_tool_id in tool_call_ids
assert regular_tool_id in tool_call_ids
+7 -7
View File
@@ -249,7 +249,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.0"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -261,14 +261,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
]
[[package]]
name = "langgraph"
version = "1.1.9"
version = "1.1.10"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -490,7 +490,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.10"
version = "1.0.11"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -535,7 +535,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]
+7 -7
View File
@@ -262,7 +262,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.3.0"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -274,14 +274,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" },
]
[[package]]
name = "langgraph"
version = "1.1.9"
version = "1.1.10"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -365,7 +365,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.2"
version = "4.0.3"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -413,7 +413,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.10"
version = "1.0.11"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -422,7 +422,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=1.0.0" },
{ name = "langchain-core", specifier = ">=1.3.1" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
]