Compare commits

..
Author SHA1 Message Date
Sydney Runkle a55ce01352 chore: update lockfiles for 1.2.0a2 / 4.1.0a2 2026-04-30 15:59:11 -04:00
Sydney Runkle 20dcb37825 chore: bump langgraph 1.2.0a2, langgraph-checkpoint 4.1.0a2 2026-04-30 15:52:22 -04:00
Will Fu-HinthornandSydney Runkle 24023da170 cleanup 2026-04-30 14:49:18 -04:00
Will Fu-HinthornandSydney Runkle 5548dbc308 fixup exit mode 2026-04-30 14:49:18 -04:00
Sydney Runkle 47dd60ceb8 style: apply ruff format fixes 2026-04-30 14:49:09 -04:00
Sydney Runkle 9f902d0cb6 test: add add_messages migration test; move all imports to module level
- Add test_add_messages_to_delta_migration_preserves_message_history (sync
  + async) covering the primary real-world BinaryOperatorAggregate →
  DeltaChannel migration path with real Message objects and IDs
- Hoist all in-function imports to module level in test_channels.py and
  fix _delta_channel_with_type helper accordingly
- Add section headers in test_channels.py for better navigation
2026-04-30 14:49:09 -04:00
Sydney Runkle d44ddd9641 nits 2026-04-30 14:49:09 -04:00
Sydney Runkle 174b3e7ba6 refactor: remove InMemorySaver.prune — out of scope for DeltaChannel PR
prune was not previously implemented on InMemorySaver (raised
NotImplementedError); adding a DeltaChannel-aware implementation is a
follow-up concern, not required for the core feature.
2026-04-30 14:49:09 -04:00
Sydney Runkle 96023296e5 refactor: remove unnecessary variable extractions from checkpoint load paths
Revert pure-style refactors (local variable hoisting, redundant null
guards, Sequence/list annotation change) that cluttered the DeltaChannel
PR diff without any semantic change.
2026-04-30 14:49:09 -04:00
Sydney RunkleandClaude Sonnet 4.6 581cde5715 fix(pregel): revert unnecessary default on increment channel param
channel: None = None -> channel: None; all call sites pass None explicitly
and the default was never needed. Restores the original signature.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:09 -04:00
Sydney RunkleandClaude Sonnet 4.6 522081d27a refactor(channels): DeltaChannel batch reducer interface + _messages_delta_reducer
Renames `operator` → `reducer` and flips arg order to `(reducer, typ=None)`,
matching the new batch contract: `reducer(state, list[writes]) -> state`. The
reducer receives all writes for a step in one call instead of being folded
pairwise, enabling single-pass implementations that avoid O(N²) reprocessing.

`typ` is now optional — `_is_field_channel` in `graph/state.py` always
overwrites it from the `Annotated[T, ...]` outer type, so users can write
`DeltaChannel(my_reducer)` rather than `DeltaChannel(list, my_reducer)`.

Adds `_messages_delta_reducer` to `langgraph.graph.message` (experimental):
a single-pass bulk reducer for message lists that deduplicates by ID and
handles `RemoveMessage` tombstoning without calling `add_messages`, avoiding
repeated dedup passes that `add_messages` would incur in a fold.

Also fixes the `_delta_write_futs` mypy error in `AsyncPregelLoop` by moving
the type annotation to the class body, and unignores `new_pr_desc.md` from
the repo via `.gitignore`.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:09 -04:00
Sydney RunkleandClaude Sonnet 4.6 023bba2bde fix(pregel): async write-ordering safety for DeltaChannel via _delta_write_futs
In durability="async" mode (the default), put_writes calls are
fire-and-forget coroutines — a process crash between write submission and
checkpoint commit leaves a DELTA_SENTINEL blob with no backing writes,
causing silent data loss on replay.

AsyncPregelLoop now maintains _delta_write_futs: any write to a
DeltaChannel channel appends its asyncio.Future to this list in
accept_writes. _checkpointer_put_after_previous drains the list with
await asyncio.gather() before calling aput(), guaranteeing
checkpoint_writes are durable before the sentinel blob is committed.

The sync loop is unchanged: BackgroundExecutor.__exit__ already ensures
all background tasks complete before invoke() returns.

Also fixes DeltaChannel(list, add_messages) constructor call in
checkpoint-postgres async test (missing typ arg).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:09 -04:00
Sydney RunkleandClaude Sonnet 4.6 cdf5682bf3 feat(serde): DELTA_SENTINEL via msgpack ext 8; remove "delta" type tag
DELTA_SENTINEL is now serialized as a msgpack ext code (EXT_DELTA_SENTINEL=8)
alongside _DeltaSnapshot (ext 7), keeping both sentinel types in the same
codec path. The dedicated "delta" string type tag and its special-case in
dumps_typed/loads_typed are removed — no migration needed since this is
introduced fresh.

InMemorySaver.prune() updated to deserialize blobs and check `is DELTA_SENTINEL`
rather than comparing the raw type tag string, making it codec-agnostic.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:09 -04:00
Sydney RunkleandClaude Sonnet 4.6 f043ffc529 refactor(channels): DeltaChannel takes typ as first arg, matching BinOpChannel
Previously DeltaChannel.__init__ hardcoded typ=list and _is_field_channel
patched item.typ/item.value after construction. This mirrors BinaryOperatorAggregate:

- DeltaChannel(typ, operator, *, snapshot_frequency=None) — typ is now
  a required first argument; __init__ strips abstract/parameterized types
  to their concrete counterparts (same logic as BinaryOperatorAggregate)
- _is_field_channel reconstructs the channel via its constructor instead
  of patching typ and value externally
- copy() and from_checkpoint() use self.__class__(self.typ, self.operator, ...)
  — no post-construction attribute hacking needed
- _empty() helper removed; self.typ() is always a concrete callable
- All call sites updated: DeltaChannel(list, op), DeltaChannel(dict, op), etc.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 41b2d00d46 refactor(channels): inline _clone_empty into copy and from_checkpoint
The helper was three lines called from exactly two places — inlining
it removes indirection without adding duplication.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 51826aab70 fix(checkpoint-postgres): type blob_values as Sequence[tuple[bytes, bytes, bytes]]
Replace Any with the concrete element type — each row is (key, type_tag,
blob) all as bytes — matching how _load_blobs unpacks and decodes them.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 361d4d4eae revert(_checkpoint-postgres): restore _load_blobs comprehension
The loop refactor added no clarity — revert to the original one-liner.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 bf2147bd96 fix(_checkpoint): remove assert, use cast for DeltaChannel narrowing
_needs_replay already gates on isinstance(spec, DeltaChannel), so the
assert/isinstance checks inside the replay branch were unreachable.
Replace with cast(DeltaChannel, spec) for zero-cost type narrowing that
survives -O and avoids any runtime check.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 7cd2e3fe1a fix(channels): address code review feedback on DeltaChannel
delta.py — value consistency:
- `__init__` now starts with `value=MISSING` (was `[]`); both fresh
  construction and clones are consistently uninitialised until
  `from_checkpoint()` or `copy()` sets the real value
- `_clone_empty` drops `__new__` in favour of the normal constructor;
  `typ` and `key` are restored explicitly afterwards (`typ` may differ
  from `list` when set via Annotated injection; `key` is injected by
  the graph builder after construction)

delta.py — snapshot cadence:
- `is_snapshot_step` now guards `step > 0`; snapshots fire at steps N,
  2N, 3N, … instead of also at step 0 where `0 % N == 0` always held

_checkpoint.py — runtime guards:
- replace both `assert isinstance(spec, DeltaChannel)` with proper
  `if not isinstance: raise TypeError`; `assert` is stripped by `-O`
  and is wrong for production invariant checks

binop.py — avoid unnecessary allocation:
- `_get_overwrite`: replace `set(value.keys()) == {OVERWRITE}` with
  `len(value) == 1 and OVERWRITE in value` to avoid allocating a
  throwaway set on every call

checkpoint-postgres — typed rows:
- add `_DeltaCombinedRow(TypedDict, total=False)` documenting the nine
  columns emitted by `SELECT_DELTA_COMBINED_SQL`'s UNION ALL; change
  `_build_delta_channel_writes_history` parameter from `Sequence[Any]`
  to `Sequence[_DeltaCombinedRow]`; call sites cast the psycopg
  `DictRow` result accordingly

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 039729a70d refactor(channels): extract _operators_equal helper, deduplicate __eq__ logic
Both BinaryOperatorAggregate and DeltaChannel had identical inline logic for
comparing operators that may be lambdas. Extract _operators_equal into
binop.py (alongside _get_overwrite) and use it in both __eq__ methods.

Also removes the duplicate _get_overwrite definition from delta.py — it was
identical to binop.py's and is now imported from there instead, along with
the now-unused OVERWRITE constant and Overwrite imports.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 1e3a78a2c7 fix(checkpoint-postgres): remove unused _DeltaSnapshot import
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 13eeb95b9f fix(checkpoint): remove unused _DeltaSnapshot import
Dropped after removing the _DeltaSnapshot special-case in the seed-terminator
logic — write-collection ordering fix handles both blob types uniformly.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 54ee1b5b1e fix(checkpoint-postgres): single-roundtrip CTE query + write-ordering fix
Replaces the three sequential SELECT roundtrips in _get_channel_writes_history
(checkpoints, checkpoint_writes, checkpoint_blobs) with one combined
UNION ALL query tagged by a _kind discriminator column. Both sync and async
paths now do one execute + one fetchall regardless of pipeline mode.

_build_delta_channel_writes_history is updated to accept the single tagged
rows list and dispatch on _kind while building its lookup dicts; the three
old SQL constants are removed.

Also fixes write-collection ordering in _build_delta_channel_writes_history:
the seed-terminator blob check previously fired before collecting that
ancestor's writes, silently dropping the transition writes needed to
reconstruct the child's state. Writes are now collected first.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 88e06f16ca fix(checkpoint): diamond pattern replaces ContextVar re-entrancy guard
Adds _get_tuple_raw / _aget_tuple_raw as the pure-storage-read layer that
_get_channel_writes_history calls instead of get_tuple. Default
implementation delegates to get_tuple for full backward compatibility — no
changes needed for existing savers whose get_tuple is a plain storage query.

Savers that perform channel hydration inside get_tuple can override
_get_tuple_raw with the raw read to structurally break any possible cycle;
a Python RecursionError surfaces the problem if they don't, rather than the
previous silent data corruption (returning empty writes).

Also fixes write-collection ordering in the reference implementation: pending
writes from the seed-terminator ancestor were silently dropped because the
terminator check fired before the collection loop. Writes are now collected
first so the seed ancestor's transition writes are included in reconstruction.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 b0a9ca7daa fix(channels): DeltaChannel subclass safety and order-independent Overwrite
copy() and from_checkpoint() hardcoded DeltaChannel instead of
self.__class__, breaking subclasses. Now mirrors the BinaryOperatorAggregate
pattern: self.__class__(self.operator) with explicit typ/key assignment.

update() applied non-overwrite values that arrived before an Overwrite in
the sequence, then discarded them when the Overwrite fired — order-dependent
behaviour in a method whose contract says order is arbitrary. Now pre-scans
for an Overwrite and applies only it (or folds all values normally if none).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 b59daa1f7c fix(channels): correct _strip_extras dead branch for Required/NotRequired
The second `if hasattr(t, "__origin__")` block was unreachable — the first
branch always returned, so Required[T] / NotRequired[T] resolved to the bare
class instead of the inner type. Check Required/NotRequired before the generic
__origin__ fallback.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
78938ec037 feat(langgraph): DeltaChannel snapshot_frequency — bounded read depth with write-count snapshotting (#7634)
## Summary

Builds on #7586. Adds `snapshot_frequency: int | None` to
`DeltaChannel`, letting users trade storage for bounded read depth. Also
promotes `channels/_delta.py` from private to public
(`channels/delta.py`).

### How it works

Every Nth **pregel step**, `create_checkpoint` writes a `_DeltaSnapshot`
blob instead of `DELTA_SENTINEL`. The ancestor walk in
`_get_channel_writes_history` terminates at the snapshot rather than
walking the full chain, bounding replay to at most N steps.

Snapshots are **eager**: fired even on steps where the channel had no
write (via a `get_next_version` version bump), so the depth bound holds
unconditionally — no risk of the cadence drifting if a channel happens
to be silent at a snapshot step.

### Storage formula

| Mode | Blob storage | Read depth |
|------|-------------|------------|
| `snapshot_frequency=None` (pure delta) | O(N) — sentinels only | O(N)
steps |
| `snapshot_frequency=K` | O(N²/K) — periodic snapshots of growing size
| O(K) steps |
| add_messages / BinOp | O(N²) — full blob every step | O(1) |

At N turns with ~400 char/msg messages, total snapshot storage ≈ N²/(2K)
× avg_msg_size, since each snapshot blob grows linearly with accumulated
messages.

### Key design decisions

- **Step-based**: `snapshot_frequency=K` means "snapshot every K pregel
steps." `create_checkpoint` has the step number; the channel itself
doesn't need to track writes.
- **Eager**: version-bumped via `get_next_version` even on non-write
steps so `put()` always stores the blob.
- **`_DeltaSnapshot` NamedTuple + msgpack ext type**
(`EXT_DELTA_SNAPSHOT = 7`): serde type tag dispatches in
`from_checkpoint` — no dict key inspection, no collision risk.
- **`from_checkpoint` semantics**: `_DeltaSnapshot` → restore value
directly (no replay needed); `DELTA_SENTINEL` / `MISSING` → replay from
ancestor writes; plain value → pre-migration BinOp blob.
- **InMemorySaver and PostgresSaver updated**:
`_get_channel_writes_history` collects the snapshot ancestor's
pending_writes before terminating (they encode the *next* step's
transition, unlike pre-delta migration blobs which subsume their own
writes).
- **`snapshot_frequency=None`** is the pure-delta default (replaces
`math.inf`).

### Benchmark results (InMemory, ~400 char/msg)

**Storage**

| turns | ctx | freq=1 | freq=5 | freq=10 | freq=50 | freq=inf |
|------:|----:|-------:|-------:|--------:|--------:|---------:|
| 50 | ~10K tok | 5.9 MB | 1.2 MB | 601.3 KB | 119.8 KB | 29.5 KB |
| 100 | ~20K tok | 23.7 MB | 4.8 MB | 2.4 MB | 475.8 KB | 58.4 KB |
| 200 | ~40K tok | 94.6 MB | 19.0 MB | 9.5 MB | 1.9 MB | 116.4 KB |
| 500 | ~100K tok | 591.5 MB | 118.4 MB | 59.2 MB | 11.8 MB | 290.3 KB |

**Read latency** (avg of 5 `get_state` calls)

| turns | ctx | freq=1 | freq=5 | freq=10 | freq=50 | freq=inf |
|------:|----:|-------:|-------:|--------:|--------:|---------:|
| 50 | ~10K tok | 0.4ms | 0.4ms | 0.7ms | 0.9ms | 1.8ms |
| 100 | ~20K tok | 0.7ms | 0.9ms | 1.0ms | 1.7ms | 5.7ms |
| 200 | ~40K tok | 1.5ms | 1.7ms | 4.5ms | 3.7ms | 20.1ms |
| 500 | ~100K tok | 3.6ms | 4.2ms | 4.4ms | 9.0ms | 110.3ms |

**Per-invoke write latency**

| turns | ctx | freq=1 | freq=5 | freq=10 | freq=50 | freq=inf |
|------:|----:|-------:|-------:|--------:|--------:|---------:|
| 50 | ~10K tok | 1.5ms | 1.1ms | 1.1ms | 1.3ms | 1.7ms |
| 100 | ~20K tok | 2.5ms | 1.6ms | 1.5ms | 1.7ms | 3.3ms |
| 200 | ~40K tok | 3.4ms | 2.3ms | 2.2ms | 2.5ms | 8.3ms |
| 500 | ~100K tok | 6.2ms | 4.2ms | 3.6ms | 4.1ms | 39.2ms |

## Test plan

- [x] `make format` / `make lint` clean across `langgraph`,
`checkpoint`, `checkpoint-postgres`
- [x] `tests/test_channels.py` — 37 passing including step-based and
eager-snapshot tests
- [x] `tests/test_delta_channel_migration.py` — all passing
- [x] Full suite: 1387 passing, 6 pre-existing failures unrelated to
this branch

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle 609da01e6b format, lint, restructure 2026-04-30 14:49:05 -04:00
Sydney Runkle 2bb37d360a internal for now 2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Opus 4.7 52eff14077 fix(postgres): handle missing checkpoint_id in _get_channel_writes_history; update test signatures
Two fixes exposed by running the postgres test suite against a local
postgres instance:

1. `PostgresSaver._get_channel_writes_history` /
   `AsyncPostgresSaver._aget_channel_writes_history` required
   `checkpoint_id` in the passed config, raising `KeyError` when called
   with just `thread_id` (e.g. `graph.aget_state({"thread_id": "..."})`).
   Now resolves to the latest checkpoint via `get_tuple`/`aget_tuple`
   when the id is missing.

2. `test_get_checkpoint_no_channel_values` (sync + async) monkeypatched
   `_load_checkpoint_tuple` with the old `(value, cur)` signature. Method
   now takes `(value)` only since delta reconstruction moved out of the
   tuple-load path — updated both tests.

Local postgres (`brew install pgvector postgresql@16`, running on port
5441) now exercises all 40 non-vector postgres tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Opus 4.7 920e2239c1 refactor(delta-channel): honest data model, private experimental API
Restructure DeltaChannel reconstruction so the hydration path matches
pregel's storage axes (blobs + writes) without leaking internal DTOs
into the public checkpoint contract.

Key changes:

* Deleted `DeltaChannelWrites` dataclass and `SEED_UNSET` sentinel.
  Reconstruction data no longer flows through `Checkpoint.channel_values`
  as a wrapped DTO — that field now carries a value or `DELTA_SENTINEL`,
  never a reconstruction shape.
* Added private `_ChannelWritesHistory(seed: Any, writes: list[PendingWrite])`
  NamedTuple as the return type for the new storage-level query.
* Added private, experimental `_get_channel_writes_history` /
  `_aget_channel_writes_history` on `BaseCheckpointSaver` — reference
  impl via `get_tuple` + `parent_config` walk, overridden on
  `InMemorySaver` / `PostgresSaver` / `AsyncPostgresSaver` for perf.
  Fixes a latent migration bug in the base fallback (now inspects
  ancestor `channel_values` for pre-delta seed).
* `DeltaChannel.from_checkpoint(seed)` simplified to two cases
  (sentinel/MISSING → empty, else → seed). New `replay_writes` method
  folds `list[PendingWrite]` through the reducer.
* Delta hydration consolidated inside `channels_from_checkpoint` via
  optional `saver` + `config` kwargs (+ async mirror
  `achannels_from_checkpoint`). All six pregel call sites updated.
  `get_tuple` no longer patches `channel_values` — removed
  `_resolve_delta_channels` (memory) and per-tuple reconstruction from
  `_load_checkpoint_tuple` (postgres sync + async).
* Hydration short-circuits on the target's own blob: if
  `channel_values[k]` is a real value (pre-migration tip, `update_state`
  result), use it directly. Only walks ancestors when the target holds
  sentinel or is missing. Fixes a correctness bug where migration-tip
  and `update_state` values would be lost.
* New test_delta_channel_migration.py: 10 scenarios covering
  BinaryOperatorAggregate → DeltaChannel migration (basic + async,
  time-travel, fork, `update_state`, tip-of-pre-migration, base-saver
  fallback parity, cross-thread isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle 79f5511e24 refactor(delta-channel): drop snapshot_every and saver Overwrite terminator
snapshot_every was a knob for bounding reconstruction cost on deep threads.
Benchmarks (notes/add_messages_replay_problem.md + scratch work on
sr/add-messages-replay-bench) showed the add_messages fast-path
(optimize/add-messages-fast-path) closes the quadratic replay cost for
threads under ~1000 turns, where the crossover to snapshots makes sense.
For deeper threads we'll ship a first-class compaction primitive instead.

Removals:

* DeltaChannel: snapshot_every ctor param, _writes_since_snapshot counter,
  should_snapshot() / snapshot_write() methods, counter threading through
  _apply_write / update / from_checkpoint / copy.
* Pregel loop: post-checkpoint snapshot-injection block and
  SNAPSHOT_TASK_ID import + constant.
* Checkpoint base: _overwrite_types() helper and the ancestor-walk
  short-circuit on user-emitted Overwrite in sync + async
  get_channel_writes.
* InMemory + Postgres savers: same walk-terminator shortcut. The
  pre-delta blob terminator (seed-from-ancestor-blob) stays — it's
  required for migration correctness, not a snapshot optimization.
* Tests for all of the above.

Preserved:

* Channel-level Overwrite semantics in DeltaChannel / BinOpAggregate:
  Overwrite still resets the value at reducer level; same-super-step
  dedup and InvalidUpdateError on multiple Overwrites still enforced.
* Pre-delta migration seeding.
2026-04-30 14:49:05 -04:00
Sydney Runkle 38c9ce4213 refactor(delta-channel): plain SELECT WHERE replaces recursive CTE
The recursive CTE was bottlenecked by a JSON-expression join
(`bl.version = checkpoint->'channel_versions'->>bl.channel`) that the
planner could not index, producing an O(ancestors x blobs) nested-loop.
At depth 1000 it ran ~275 ms and removed ~2M filter rows; the recursion
itself was 2.4 ms.

Switch to three plain indexed SELECTs per delta channel
(checkpoints, checkpoint_writes, checkpoint_blobs); a pure helper on
BasePostgresSaver walks the parent chain and assembles
DeltaChannelWrites. Sync (__init__.py) and async (aio.py) each own
their three-roundtrip I/O wrappers.

Bench numbers (notes/delta_channel_query_bench.md): 3x at depth 50,
15x at depth 200, ~100x at depth 1000. Plain over-fetches sibling rows
when the thread branches but still wins at every realistic depth on
both local and remote postgres.

Multi-channel coalescing dropped — reconstruction is per-channel now.
Same shape as InMemorySaver. Can come back as a SQL-level
optimization later if needed.
2026-04-30 14:49:05 -04:00
Sydney Runkle 9227339184 contextvar 2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Opus 4.7 ffacba950a fix(delta-channel): target-exclusion, pre-delta seed, one-query postgres walk
Four fixes from an independent review of the reconstruction pipeline, plus
a structural cleanup:

1. Ancestor walk excludes the target checkpoint itself (matches pregel:
   writes stored under checkpoint_id=T are pending for the NEXT step and
   applied separately via apply_writes). Memory saver previously included
   them, diverging from Postgres and causing pending writes to be folded
   into the reconstructed snapshot — visible via get_state during
   interrupts and time-travel into a non-leaf checkpoint.

2. Pre-delta blob terminator. When the walk hits an ancestor whose blob
   for the channel is a real value (not DELTA_SENTINEL), bind that blob
   as DeltaChannelWrites.seed and stop. Without this, threads migrated
   from pre-delta storage would replay ancestor writes to the root
   forever AND lose any value that lived only in the old blob
   (e.g. from update_state). Per-ancestor, the blob is checked BEFORE
   its writes — a pre-delta blob subsumes writes at the same checkpoint,
   so including them would double-count.

3. Base-fallback get_channel_writes follows parent_checkpoint_id instead
   of list(before=...). The previous form returned every tuple with
   id<target, including sibling branches on forked threads.

4. seed replaces the Overwrite-wrapping hack for pre-delta values.
   DeltaChannelWrites(writes, seed=SEED_UNSET) makes the saver's
   reconstruction terminator semantically explicit; drops the lazy
   _make_overwrite import dance. User-emitted Overwrite still reset the
   chain via _apply_write as before.

Postgres: recursive CTE enumerates on-path ancestors and joins once
against checkpoint_writes and once against checkpoint_blobs for every
delta channel in the get_tuple — one roundtrip instead of the previous
3 queries × N channels.

Tests added:
- Pre-delta blob seeding (seed binding, no double-counting of ancestor
  writes at the terminator, pending-at-target excluded).
- Root checkpoint returns empty writes.
- Seed-based from_checkpoint replay (three scenarios: with writes,
  seed-only, seed=None distinct from SEED_UNSET).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle 5f1e946b1a optimizations i sure hope 2026-04-30 14:49:05 -04:00
Sydney Runkle 759eba7fd2 eh 2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Opus 4.7 9361385084 fix(delta-channel): unwrap NotRequired[X] for dict/set reducers
Annotated[NotRequired[dict[...]], DeltaChannel(reducer)] (the shape used
by deepagents' filesystem middleware) fell through type inference to
`list`, so the first operator call blew up with
"'list' object is not a mapping". `_is_field_channel` now unwraps a
parameterized Required[X]/NotRequired[X] before stripping extras, which
lets dict/set/mapping outer types reach the abc normalization block.

Also type-annotates the `new` locals in DeltaChannel.copy() and
from_checkpoint() so mypy can infer them through the abstract return
type.

Adds tests covering: dict Overwrite in update and in writes replay,
snapshot_write with a dict reducer, dict backwards-compat checkpoints,
NotRequired type inference, and a filesystem-shaped end-to-end graph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle c6231f9349 arbitrary 2026-04-30 14:49:05 -04:00
Sydney Runkle 6e89123f3f lint 2026-04-30 14:49:05 -04:00
Sydney Runkle 41edb69e08 lint 2026-04-30 14:49:05 -04:00
Sydney Runkle 80dd17791d lint and snapshot every 2026-04-30 14:49:05 -04:00
Sydney Runkle ebe24ba915 cleanup 2026-04-30 14:49:05 -04:00
Sydney Runkle 17f2a8ea28 chore(delta-channel): add PR description 2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 57e53fc600 chore(delta-channel): remove snapshot_every — simpler design, better storage savings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle eb0687a108 refactor(postgres): replace recursive CTE with two-query ancestor walk for DeltaChannel
Instead of a recursive SQL CTE, collect the ancestor checkpoint ID chain in
Python by fetching all (checkpoint_id, parent_checkpoint_id) for the thread
in one query, then fetch writes with a plain WHERE checkpoint_id = ANY(...).

Simpler, avoids recursive query planner overhead, and uses well-indexed lookups.
2026-04-30 14:49:05 -04:00
Sydney Runkle 4a6ddbb0e3 feat(delta-channel): store sentinel in blobs, reconstruct from checkpoint_writes
DeltaChannel.checkpoint() now returns a zero-byte DeltaChannelSentinel
instead of duplicating delta data in checkpoint_blobs. Reconstruction
walks the parent checkpoint chain via checkpoint_writes (which already
holds per-step writes) and replays them through the operator.

In-memory benchmark (100 turns, ~20K tokens):
  storage: 10.2 MB → 40.5 KB (251x reduction)
  read:    0.6ms → 7.9ms (reconstruction cost, amortized by storage savings)

InMemorySaver and PostgresSaver override get_channel_writes() with
efficient implementations (Python dict walk and recursive CTE respectively).
The base class fallback uses self.list() with a thread-local recursion guard.
2026-04-30 14:49:05 -04:00
Sydney Runkle 5d72a3f390 refactor(delta-channel): infer typ from Annotated outer type instead of constructor arg
Remove the positional `typ` parameter from `DeltaChannel.__init__`. The type is
now injected automatically from the `Annotated` outer type in `_is_field_channel`
(matching how `BinaryOperatorAggregate` receives its type). `copy()` and
`from_checkpoint()` propagate `self.typ` explicitly. Test helpers updated to
use `_get_channel` with the proper `Annotated` path.
2026-04-30 14:49:05 -04:00
Sydney Runkle 49ad3044a7 chore(delta-channel): remove supports_delta_channels flag
Rely on the runtime raise in DeltaChannel.from_checkpoint() instead of
a compile-time boolean flag. Savers that assemble DeltaChainValue inside
_load_blobs work transparently; savers that don't will pass through a raw
DeltaValue and hit a clear ValueError on first reload.

Removes: BaseCheckpointSaver.supports_delta_channels, the attribute on
InMemorySaver / PostgresSaver / AsyncPostgresSaver, the compile-time
UserWarning in StateGraph.compile(), and the associated test.
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 4dd795354a chore: remove docs/ from PR
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
ccurmeandSydney Runkle 51cd095427 fix(prebuilt): handle injected NotRequired keys (#7392)
Resolves https://github.com/langchain-ai/langchain/issues/35585

This would previously raise KeyError:
```python
from typing import Annotated

from langchain_core.tools import tool
from langchain.agents import create_agent
from typing_extensions import NotRequired
from langgraph.prebuilt import InjectedState
from langchain.agents import AgentState


class CustomAgentState(AgentState):
    city: NotRequired[str]


@tool
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
    """Get weather for a given city."""
    if city is None:
        city = "Boston"
    return f"It's always sunny in {city}!"


agent = create_agent(
    model="claude-sonnet-4-6",
    tools=[get_weather],
    system_prompt="You are a helpful assistant",
    state_schema=CustomAgentState,
)

input_message = {
    "role": "user",
    "content": "What's the weather?",
}

result = agent.invoke({"messages": [input_message]})
for m in result["messages"]:
    m.pretty_print()
```

---------

Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle de410e21b4 lint 2026-04-30 14:49:05 -04:00
Sydney Runkle a5f4edc093 latest 2026-04-30 14:49:05 -04:00
Sydney Runkle c185102642 fix(delta-channel): support non-list reducers (dict) and fix MISSING handling
Use typ() instead of [] throughout DeltaChannel so reducers over dict
(and other non-list types) work correctly. fromCheckpoint(MISSING) now
leaves value as typ() from __init__ instead of overwriting with MISSING.
copy() uses value.copy() to handle dicts. update() initialises base from
typ() when value is MISSING. Add four tests covering the deepagents-style
dict-merge / file-deletion reducer pattern.
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 b993e2d37d chore(delta): rename _steps_since_rehydrate → _steps_since_snapshot; add audit tests
- Rename `_steps_since_rehydrate` → `_steps_since_snapshot` in DeltaChannel
  for clarity (counts steps since the last snapshot, not since rehydration)
- Pre-seed cycle-detection `visited` set with current checkpoint ID in both
  sync and async `_assemble_delta_channels` to prevent self-referential chains
- Add 4 new unit tests:
  - `test_delta_channel_snapshot_every_emits_plain_list`: verifies counter
    semantics and snapshot/delta transitions
  - `test_delta_channel_snapshot_every_end_to_end`: graph-level smoke test
  - `test_delta_channel_assembly_fast_path_returns_delta_value`: exercises
    chain traversal via get_channel_blob returning DeltaValue then plain list
  - `test_delta_channel_assembly_broken_chain_logs_warning`: partial chain
    when get_tuple returns None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 07cd31ccf8 chore: rename serde type tag "diff" → "delta" for DeltaValue
Consistent with channel/type naming (DeltaChannel, DeltaValue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 baa748e81b chore: apply format/lint fixes across checkpoint, checkpoint-postgres, prebuilt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 2d39d536fe fix: register DeltaValue in SAFE_MSGPACK_TYPES; rename _is_diff_delta; cross-saver benchmark
- Add DeltaValue to SAFE_MSGPACK_TYPES so SQLite and other msgpack-based
  savers don't emit "Deserializing unregistered type" warnings.
- Rename _is_diff_delta → _is_delta_value (leftover from DiffChannel rename).
- Parametrize benchmark by checkpointer: runs InMemory (fast-path) and
  SQLite (get_tuple fallback) in the same table, sharing the _run_turns helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney RunkleandClaude Sonnet 4.6 c07f081e4c fix(delta-channel): fix chain assembly and get_state paths
- Fix InMemorySaver.get_channel_blob: use correct storage[thread_id][ns]
  nesting and deserialize the checkpoint before extracting channel_versions.
- Pass checkpoint_id to after_checkpoint() in channels_from_checkpoint so
  DeltaChannel seeds _last_checkpoint_id correctly on load; without this
  every turn broke the chain at its boundary.
- Wire _assemble_delta_channels into _prepare_state_snapshot and
  _aprepare_state_snapshot (get_state / get_state_history paths) and into
  perform_superstep / aperform_superstep (update_state paths) — previously
  only the loop __enter__ path did assembly.
- Fix test_get_channel_blob to use the correct storage structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:49:05 -04:00
Sydney Runkle 549b974e48 test(channels): replace unsupported-saver raise test with fallback assembly test 2026-04-30 14:49:05 -04:00
Sydney Runkle f083877fde feat(postgres): remove _load_diff_chains; add get_channel_blob / aget_channel_blob 2026-04-30 14:44:39 -04:00
Sydney Runkle 6df84d7421 feat(memory): implement get_channel_blob; remove diff handling from _load_blobs 2026-04-30 14:44:39 -04:00
Sydney Runkle fe783a53f0 feat(pregel): wire DeltaChannel assembly into loop; pass checkpoint_id to after_checkpoint 2026-04-30 14:44:39 -04:00
Sydney Runkle c2da9788c3 feat(pregel): add _assemble_delta_channels helpers for universal DeltaChannel support 2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 683e0bb89d feat(channels): DeltaChannel tracks checkpoint_id; emits prev_checkpoint_id in DeltaValue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 5169786ded feat(serde): diff type encodes prev_checkpoint_id; loads_typed returns DeltaValue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney Runkle aa312d18e1 docs(checkpoint): expand aget_channel_blob docstring for parity 2026-04-30 14:44:39 -04:00
Sydney Runkle db33a67804 chore: add .worktrees/ to .gitignore 2026-04-30 14:44:39 -04:00
Sydney Runkle f41dfb292c feat(checkpoint): DeltaValue uses prev_checkpoint_id; add get_channel_blob stubs 2026-04-30 14:44:39 -04:00
Sydney Runkle ea3e595379 chore: rename DiffChannel/DiffDelta/DiffChainValue to Delta* across libs
Renames the diff-channel types to DeltaChannel, DeltaValue, and DeltaChainValue
for consistency with the settled naming convention.
2026-04-30 14:44:39 -04:00
Sydney Runkle 3ea6da3771 more tests 2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 8f776498e9 chore: format/lint fixes for rehydrate_every benchmark
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 f53ae61576 feat(channels): add rehydrate_every to DiffChannel for bounded chain traversal
Periodic full-snapshot checkpoints cap chain depth, trading a small
amount of extra storage for bounded reconstruction time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 4856c89b30 test(channels): add DiffChannel vs BinaryOperatorAggregate storage/time benchmark
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 800a2151ba chore: format and lint fixes for DiffChannel implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 d5507cb53c fix(checkpoint/postgres): pass cursor to avoid deadlock in diff chain traversal
Fixes a critical deadlock that occurs when _load_diff_chains calls self._cursor()
from within _load_blobs while the outer _load_checkpoint_tuple already holds
self._cursor(). On bare (non-pool) connections, the threading.Lock is not
reentrant, causing a deadlock.

Solution: Pass the cursor as a parameter to _load_diff_chains and _load_blobs
instead of acquiring a new cursor within those methods. Updated _load_checkpoint_tuple
to acquire a cursor once at the top level and pass it through the call chain.

Changes:
- Updated _load_blobs signature to accept optional cur parameter
- Updated _load_diff_chains signature (base and implementations) to accept optional cur parameter
- Modified _load_checkpoint_tuple in PostgresSaver to acquire cursor and pass it
- Modified _load_checkpoint_tuple_async to acquire cursor only when diff_payloads exist
- Removed nested self._cursor() calls in _load_diff_chains and _load_diff_chains_async

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 afc2e12201 feat(checkpoint/postgres): diff chain reconstruction in async saver
Add `_load_diff_chains_async` to `AsyncPostgresSaver` and override
`_load_checkpoint_tuple` to inline blob-parsing and diff-chain
resolution via async point-lookup traversal, mirroring the sync
`PostgresSaver._load_diff_chains` implementation. Add integration test
`test_diff_channel_chain_reconstruction` that skips gracefully when
`langgraph` core is not installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 f5b27e8cda feat(checkpoint/postgres): diff chain reconstruction in _load_blobs (sync)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:39 -04:00
Sydney RunkleandClaude Sonnet 4.6 a7b6dd159c test(pregel): strengthen DiffChannel time-travel and reply assertions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 5baa791b82 feat(pregel): call after_checkpoint hook when loading and saving channels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 b57ff79f4b fix(checkpoint/memory): warn on broken diff chain, guard against cycles
- Add logger.warning when a mid-chain blob is missing (fixes silent truncation bug)
- Add cycle guard to prevent infinite loops on corrupt blob stores
- Fix type annotation on diff_channels from dict[str, Any] to dict[str, str]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 6d25ed2738 feat(checkpoint/memory): chain-traverse diff blobs in _load_blobs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney Runkle ac9531378d fix(channels): align DiffChannel.is_available with BinaryOperatorAggregate 2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 c9e9e09a9e feat(channels): implement DiffChannel for incremental checkpoint storage
Adds DiffChannel, a new channel type that stores only per-step write
deltas in checkpoints and reconstructs the full list by replaying the
chain through the operator at load time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney Runkle 3bb225b34f feat(channels): add no-op after_checkpoint hook to BaseChannel 2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 ded1dbba2f fix(checkpoint/serde): use lazy isinstance check for DiffDelta
Replace duck-typing check with lazy import inside _is_diff_delta helper
function to avoid module-level circular dependency while using proper
isinstance semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.5 99bc8bb9af feat(checkpoint/serde): serialize DiffDelta as 'diff' type tag
Add serde support for DiffDelta by implementing dump/load for the "diff" type tag.
This allows the checkpoint system to efficiently store delta objects by serializing
them as msgpack-encoded dicts with {"d": delta, "p": prev_version} structure.

The implementation uses runtime type checking to avoid circular imports and
leverages the existing msgpack ext hooks for proper deserialization of complex
types like LangChain messages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 77f8f4948c feat(checkpoint): add DiffDelta and DiffChainValue protocol types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 20d1b6a435 docs: add DiffChannel implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandClaude Sonnet 4.6 ee00e733ba docs: add DiffChannel incremental checkpoint storage design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 14:44:38 -04:00
Sydney RunkleandGitHub a529b9bede chore(langgraph): bump version 1.1.8 -> 1.1.9 (#7563)
## Summary

- Bumps `langgraph` patch version from `1.1.8` to `1.1.9` in
`libs/langgraph/pyproject.toml`

## Test plan

- [ ] Verify version string is correct in `pyproject.toml`
- [ ] Confirm release workflow triggers on merge
2026-04-21 09:38:58 -04:00
0a26b471d3 fix(langgraph): don't propagate ReplayState to subgraphs on plain resume (#7561)
**Description:**
When clients resume an interrupted subgraph with `Command(resume=...)`
plus an explicit `checkpoint_id` in the config (the pattern LangGraph
Studio and the API server emit on every resume), the subgraph restarts
from its first node instead of continuing at the interrupted node.

Fix: gate `ReplayState` propagation on `is_time_traveling` rather than
`is_replaying`, so a resume that happens to carry a head checkpoint_id
behaves the same as one with just a thread_id.

**Verification:** added regression test
`test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id` (fails
on main, passes with fix, across memory/sqlite/sqlite_aes).
Full `test_time_travel.py`, `test_time_travel_async.py`,
`test_interruption.py`, and all pregel subgraph/interrupt/resume/replay
tests still pass.

Co-authored-by: Jessie Ibarra <jessie.ibarra@langgraph.dev>
2026-04-20 21:29:18 -04:00
Eugene YurtsevandGitHub b674dd4622 feat(prebuilt): expose available tools on ToolRuntime (#7512) 2026-04-17 16:54:16 -04:00
Eugene YurtsevandGitHub 8df0a377d0 chore(langgraph): undo unnecessary changes in stream handler (#7536)
This change is no longer necessary as langchain-core will continue
copying checkpoint_ns for backwards compatibility
2026-04-17 16:22:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
216cf33a54 chore(deps): bump the pip group across 3 directories with 1 update (#7537)
Bumps the pip group with 1 update in the
/libs/cli/examples/graph_prerelease_reqs/deps/zuper_deps directory:
[langchain-openai](https://github.com/langchain-ai/langchain).
Bumps the pip group with 1 update in the
/libs/cli/examples/graph_prerelease_reqs_fail directory:
[langchain-openai](https://github.com/langchain-ai/langchain).
Bumps the pip group with 1 update in the
/libs/cli/examples/graph_prerelease_reqs directory:
[langchain-openai](https://github.com/langchain-ai/langchain).

Updates `langchain-openai` from 1.0.1 to 1.1.14
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-openai's
releases</a>.</em></p>
<blockquote>
<h2>langchain-openai==1.1.14</h2>
<p>Changes since langchain-openai==1.1.13</p>
<p>release(openai): 1.1.14 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36820">#36820</a>)
fix(openai): use SSRF-safe transport for image token counting (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36819">#36819</a>)
chore(deps): bump pytest to <code>9.0.3</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36801">#36801</a>)
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36795">#36795</a>)
chore: bump pillow from 12.1.1 to 12.2.0 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36777">#36777</a>)</p>
<h2>langchain-openai==1.1.13</h2>
<p>Changes since langchain-openai==1.1.12</p>
<p>release(openai): 1.1.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36729">#36729</a>)
fix(openai): handle content blocks without type key in responses api
conversion (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36725">#36725</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36539">#36539</a>)
chore(openai): fix broken vcr cassette playback and add ci guard (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36502">#36502</a>)
fix(openai,groq,openrouter): use is-not-None checks in usage metadata
token extraction (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36500">#36500</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36455">#36455</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36368">#36368</a>)
fix(openai): update computer call test (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36352">#36352</a>)
fix(openai): let user-provided User-Agent override the Azure default (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35523">#35523</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36248">#36248</a>)</p>
<h2>langchain-openai==1.1.12</h2>
<p>Changes since langchain-openai==1.1.11</p>
<p>fix(openai): bump min core version (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36180">#36180</a>)
release(openai): 1.1.12 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36178">#36178</a>)
fix(core,model-profiles): add missing <code>ModelProfile</code> fields,
warn on schema drift (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36129">#36129</a>)
fix(openai): support phase parameter (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36161">#36161</a>)
fix(openai): preserve namespace field in streaming function_call chunks
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36108">#36108</a>)
ci: suppress pytest streaming output in CI (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36092">#36092</a>)
ci: avoid unnecessary dep installs in lint targets (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36046">#36046</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36039">#36039</a>)
chore: bump orjson from 3.11.5 to 3.11.6 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35860">#35860</a>)
fix(openai): add type: message to Responses API input items (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35693">#35693</a>)
perf(.github): set a timeout on get min versions HTTP calls (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35851">#35851</a>)
feat(model-profiles): new fields + <code>Makefile</code> target (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35788">#35788</a>)
fix(openai): close PIL Image handles in token counting to prevent fd
leak (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35742">#35742</a>)
fix(openai): typo (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35763">#35763</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35754">#35754</a>)</p>
<h2>langchain-openai==1.1.11</h2>
<p>Changes since langchain-openai==1.1.10</p>
<p>fix(openai): bump min core version (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35705">#35705</a>)
release(openai): 1.1.11 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35703">#35703</a>)</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b7447c6969fc928ec3f29c200e2e56c0a46c4c77"><code>b7447c6</code></a>
fix(infra): skip serdes tests in min-version release step (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36818">#36818</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/41c0cc58b0dac82000d24715f7a4b44dc8b01fd3"><code>41c0cc5</code></a>
release(openai): 1.1.14 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36820">#36820</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/0516156ef98f5001129f6d47bc8682d6536d58fb"><code>0516156</code></a>
fix(openai): use SSRF-safe transport for image token counting (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36819">#36819</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/338aa8131a8124e7aa1e042616ccd2366ff9f699"><code>338aa81</code></a>
fix(core): restore cloud metadata IPs and link-local range in SSRF
policy (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/3">#3</a>...</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/51e954877efd2d2c3c5bf09364dcfec8794eadb0"><code>51e9548</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/text-splitters (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36797">#36797</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/e85c418cfa559d4a794ddc6db92c6febab44651c"><code>e85c418</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/model-profiles (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36798">#36798</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/789126e6c78ad74664bea26228dda6e72e135dce"><code>789126e</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/standard-tests (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36799">#36799</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/937b3eb3827551d17ee4736f9acc4aa57e88c716"><code>937b3eb</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/langchain_v1 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36800">#36800</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/a06c205738cf5953e28c37287ddb1559d67c01f6"><code>a06c205</code></a>
ci(infra): validate issue checkboxes by section (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36811">#36811</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/aa33b06deb0d65489ce254b48a8aaf8a86304c18"><code>aa33b06</code></a>
fix(langchain-classic): suppress mypy errors in compat code (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36806">#36806</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-openai==1.0.1...langchain-openai==1.1.14">compare
view</a></li>
</ul>
</details>
<br />

Updates `langchain-openai` from 1.0.0a2 to 1.1.14
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-openai's
releases</a>.</em></p>
<blockquote>
<h2>langchain-openai==1.1.14</h2>
<p>Changes since langchain-openai==1.1.13</p>
<p>release(openai): 1.1.14 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36820">#36820</a>)
fix(openai): use SSRF-safe transport for image token counting (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36819">#36819</a>)
chore(deps): bump pytest to <code>9.0.3</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36801">#36801</a>)
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36795">#36795</a>)
chore: bump pillow from 12.1.1 to 12.2.0 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36777">#36777</a>)</p>
<h2>langchain-openai==1.1.13</h2>
<p>Changes since langchain-openai==1.1.12</p>
<p>release(openai): 1.1.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36729">#36729</a>)
fix(openai): handle content blocks without type key in responses api
conversion (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36725">#36725</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36539">#36539</a>)
chore(openai): fix broken vcr cassette playback and add ci guard (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36502">#36502</a>)
fix(openai,groq,openrouter): use is-not-None checks in usage metadata
token extraction (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36500">#36500</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36455">#36455</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36368">#36368</a>)
fix(openai): update computer call test (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36352">#36352</a>)
fix(openai): let user-provided User-Agent override the Azure default (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35523">#35523</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36248">#36248</a>)</p>
<h2>langchain-openai==1.1.12</h2>
<p>Changes since langchain-openai==1.1.11</p>
<p>fix(openai): bump min core version (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36180">#36180</a>)
release(openai): 1.1.12 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36178">#36178</a>)
fix(core,model-profiles): add missing <code>ModelProfile</code> fields,
warn on schema drift (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36129">#36129</a>)
fix(openai): support phase parameter (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36161">#36161</a>)
fix(openai): preserve namespace field in streaming function_call chunks
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36108">#36108</a>)
ci: suppress pytest streaming output in CI (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36092">#36092</a>)
ci: avoid unnecessary dep installs in lint targets (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36046">#36046</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36039">#36039</a>)
chore: bump orjson from 3.11.5 to 3.11.6 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35860">#35860</a>)
fix(openai): add type: message to Responses API input items (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35693">#35693</a>)
perf(.github): set a timeout on get min versions HTTP calls (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35851">#35851</a>)
feat(model-profiles): new fields + <code>Makefile</code> target (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35788">#35788</a>)
fix(openai): close PIL Image handles in token counting to prevent fd
leak (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35742">#35742</a>)
fix(openai): typo (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35763">#35763</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35754">#35754</a>)</p>
<h2>langchain-openai==1.1.11</h2>
<p>Changes since langchain-openai==1.1.10</p>
<p>fix(openai): bump min core version (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35705">#35705</a>)
release(openai): 1.1.11 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35703">#35703</a>)</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b7447c6969fc928ec3f29c200e2e56c0a46c4c77"><code>b7447c6</code></a>
fix(infra): skip serdes tests in min-version release step (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36818">#36818</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/41c0cc58b0dac82000d24715f7a4b44dc8b01fd3"><code>41c0cc5</code></a>
release(openai): 1.1.14 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36820">#36820</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/0516156ef98f5001129f6d47bc8682d6536d58fb"><code>0516156</code></a>
fix(openai): use SSRF-safe transport for image token counting (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36819">#36819</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/338aa8131a8124e7aa1e042616ccd2366ff9f699"><code>338aa81</code></a>
fix(core): restore cloud metadata IPs and link-local range in SSRF
policy (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/3">#3</a>...</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/51e954877efd2d2c3c5bf09364dcfec8794eadb0"><code>51e9548</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/text-splitters (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36797">#36797</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/e85c418cfa559d4a794ddc6db92c6febab44651c"><code>e85c418</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/model-profiles (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36798">#36798</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/789126e6c78ad74664bea26228dda6e72e135dce"><code>789126e</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/standard-tests (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36799">#36799</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/937b3eb3827551d17ee4736f9acc4aa57e88c716"><code>937b3eb</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/langchain_v1 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36800">#36800</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/a06c205738cf5953e28c37287ddb1559d67c01f6"><code>a06c205</code></a>
ci(infra): validate issue checkboxes by section (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36811">#36811</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/aa33b06deb0d65489ce254b48a8aaf8a86304c18"><code>aa33b06</code></a>
fix(langchain-classic): suppress mypy errors in compat code (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36806">#36806</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-openai==1.0.1...langchain-openai==1.1.14">compare
view</a></li>
</ul>
</details>
<br />

Updates `langchain-openai` from 1.0.0a2 to 1.1.14
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-openai's
releases</a>.</em></p>
<blockquote>
<h2>langchain-openai==1.1.14</h2>
<p>Changes since langchain-openai==1.1.13</p>
<p>release(openai): 1.1.14 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36820">#36820</a>)
fix(openai): use SSRF-safe transport for image token counting (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36819">#36819</a>)
chore(deps): bump pytest to <code>9.0.3</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36801">#36801</a>)
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36795">#36795</a>)
chore: bump pillow from 12.1.1 to 12.2.0 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36777">#36777</a>)</p>
<h2>langchain-openai==1.1.13</h2>
<p>Changes since langchain-openai==1.1.12</p>
<p>release(openai): 1.1.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36729">#36729</a>)
fix(openai): handle content blocks without type key in responses api
conversion (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36725">#36725</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36539">#36539</a>)
chore(openai): fix broken vcr cassette playback and add ci guard (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36502">#36502</a>)
fix(openai,groq,openrouter): use is-not-None checks in usage metadata
token extraction (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36500">#36500</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36455">#36455</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36368">#36368</a>)
fix(openai): update computer call test (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36352">#36352</a>)
fix(openai): let user-provided User-Agent override the Azure default (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35523">#35523</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36248">#36248</a>)</p>
<h2>langchain-openai==1.1.12</h2>
<p>Changes since langchain-openai==1.1.11</p>
<p>fix(openai): bump min core version (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36180">#36180</a>)
release(openai): 1.1.12 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36178">#36178</a>)
fix(core,model-profiles): add missing <code>ModelProfile</code> fields,
warn on schema drift (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36129">#36129</a>)
fix(openai): support phase parameter (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36161">#36161</a>)
fix(openai): preserve namespace field in streaming function_call chunks
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36108">#36108</a>)
ci: suppress pytest streaming output in CI (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36092">#36092</a>)
ci: avoid unnecessary dep installs in lint targets (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36046">#36046</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36039">#36039</a>)
chore: bump orjson from 3.11.5 to 3.11.6 in /libs/partners/openai (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35860">#35860</a>)
fix(openai): add type: message to Responses API input items (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35693">#35693</a>)
perf(.github): set a timeout on get min versions HTTP calls (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35851">#35851</a>)
feat(model-profiles): new fields + <code>Makefile</code> target (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35788">#35788</a>)
fix(openai): close PIL Image handles in token counting to prevent fd
leak (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35742">#35742</a>)
fix(openai): typo (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35763">#35763</a>)
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35754">#35754</a>)</p>
<h2>langchain-openai==1.1.11</h2>
<p>Changes since langchain-openai==1.1.10</p>
<p>fix(openai): bump min core version (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35705">#35705</a>)
release(openai): 1.1.11 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35703">#35703</a>)</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b7447c6969fc928ec3f29c200e2e56c0a46c4c77"><code>b7447c6</code></a>
fix(infra): skip serdes tests in min-version release step (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36818">#36818</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/41c0cc58b0dac82000d24715f7a4b44dc8b01fd3"><code>41c0cc5</code></a>
release(openai): 1.1.14 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36820">#36820</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/0516156ef98f5001129f6d47bc8682d6536d58fb"><code>0516156</code></a>
fix(openai): use SSRF-safe transport for image token counting (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36819">#36819</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/338aa8131a8124e7aa1e042616ccd2366ff9f699"><code>338aa81</code></a>
fix(core): restore cloud metadata IPs and link-local range in SSRF
policy (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/3">#3</a>...</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/51e954877efd2d2c3c5bf09364dcfec8794eadb0"><code>51e9548</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/text-splitters (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36797">#36797</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/e85c418cfa559d4a794ddc6db92c6febab44651c"><code>e85c418</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/model-profiles (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36798">#36798</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/789126e6c78ad74664bea26228dda6e72e135dce"><code>789126e</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/standard-tests (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36799">#36799</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/937b3eb3827551d17ee4736f9acc4aa57e88c716"><code>937b3eb</code></a>
chore: bump langsmith from 0.6.3 to 0.7.31 in /libs/langchain_v1 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36800">#36800</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/a06c205738cf5953e28c37287ddb1559d67c01f6"><code>a06c205</code></a>
ci(infra): validate issue checkboxes by section (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36811">#36811</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/aa33b06deb0d65489ce254b48a8aaf8a86304c18"><code>aa33b06</code></a>
fix(langchain-classic): suppress mypy errors in compat code (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36806">#36806</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-openai==1.0.1...langchain-openai==1.1.14">compare
view</a></li>
</ul>
</details>
<br />


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 <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
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-17 12:42:55 -07:00
Eugene YurtsevandGitHub 4956134a37 release(langgraph): 1.1.8 (#7545)
releasing 1.1.8
2026-04-17 19:41:13 +00:00
aa94790f36 fix(langgraph): remove strict add_handler type check that breaks OTel instrumentation (#7544)
## Summary

Removes the `add_handler()` overrides on `_GraphCallbackManager` and
`_AsyncGraphCallbackManager` that reject handlers not inheriting from
`GraphCallbackHandler`. This fixes a regression in 1.1.7 where
`opentelemetry-instrumentation-langchain` (and likely other libraries
that patch `BaseCallbackManager.__init__`) crash with `TypeError:
handlers must inherit GraphCallbackHandler` at invocation time.

## Why this is safe

The strict type check is redundant — `_configure_graph_callbacks` and
`_filter_graph_handlers` already filter handlers to
`GraphCallbackHandler` instances at construction time. Non-graph
handlers that enter via external patches (like OTel's monkey-patch) are
harmless because `handle_event("on_interrupt", ...)` /
`handle_event("on_resume", ...)` will simply no-op on handlers that
don't implement those methods.

## What changed

- Deleted `add_handler()` override from `_GraphCallbackManager` (was
lines 248-255)
- Deleted `add_handler()` override from `_AsyncGraphCallbackManager`
(was lines 324-331)
- No other changes — 18 lines removed, 0 added

## Test plan

- [x] All 8 existing `test_graph_callbacks.py` tests pass (`make test
TEST=tests/test_graph_callbacks.py`)
- [x] `make lint` passes
- [x] `make format` passes (no changes needed)
- [x] Verified fix locally: `LangchainInstrumentor().instrument()` +
`create_react_agent()` + `graph.ainvoke()` no longer raises `TypeError`
- [x] Verified the graph lifecycle callbacks (`on_interrupt`,
`on_resume`) still work correctly

Closes #7543

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2026-04-17 15:33:10 -04:00
ccurmeandGitHub e002711ede release(prebuilt): 1.0.10 (#7541) 2026-04-17 13:52:20 -04:00
f44b49b33d chore: dedup warnings (#7257)
Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
2026-04-17 10:13:03 -07:00
a0a95df2ac release(cli): 0.4.23 (#7542)
Release Note: Increase the max bound for langgraph-api

Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
2026-04-17 16:38:14 +00:00
Eugene YurtsevandGitHub d194c18c06 release(langgraph): 1.1.7 (#7540)
release langgraph 1.1.7
2026-04-17 15:19:18 +00:00
Sydney Runkle eae916719f CLI bump 2026-04-16 13:48:02 -04:00
f093702e4e fix(prebuilt): handle injected NotRequired keys (#7392)
Resolves https://github.com/langchain-ai/langchain/issues/35585

This would previously raise KeyError:
```python
from typing import Annotated

from langchain_core.tools import tool
from langchain.agents import create_agent
from typing_extensions import NotRequired
from langgraph.prebuilt import InjectedState
from langchain.agents import AgentState


class CustomAgentState(AgentState):
    city: NotRequired[str]


@tool
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
    """Get weather for a given city."""
    if city is None:
        city = "Boston"
    return f"It's always sunny in {city}!"


agent = create_agent(
    model="claude-sonnet-4-6",
    tools=[get_weather],
    system_prompt="You are a helpful assistant",
    state_schema=CustomAgentState,
)

input_message = {
    "role": "user",
    "content": "What's the weather?",
}

result = agent.invoke({"messages": [input_message]})
for m in result["messages"]:
    m.pretty_print()
```

---------

Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
2026-04-16 13:06:33 +00:00
Sydney RunkleandGitHub 51cbdbd5cd fix: time travel when going back to interrupt node (#7498)
# Fix: Create fork checkpoint on subgraph time travel

## Problem

When time-traveling to a subgraph checkpoint that has an interrupt, and
then resuming, the resume would load the **wrong state** — it would pick
up the original execution's latest checkpoint instead of the
time-traveled one.

This happened because replaying from a subgraph checkpoint never created
a new parent checkpoint. If the replay hit an interrupt before
`after_tick()` ran, no checkpoint was written at all, so the parent's
"latest" checkpoint was still the old one from the original execution.

## Fix

When the loop detects a time-travel replay (not an `update_state` fork),
it now **eagerly writes a fork checkpoint** at the start of the tick.
This ensures:

1. The parent thread's latest checkpoint points to the replayed state
2. Subsequent `Command(resume=...)` calls find the correct checkpoint
3. Stale `INTERRUPT` pending writes from the old checkpoint are cleared
(they reference old task IDs)

Additionally, the subgraph replay logic now uses the **parent checkpoint
ID** (from `prev_checkpoint_config`) when resolving subgraph checkpoints
during time-travel, matching the existing behavior for `update_state`
forks.

## Checkpoint flow diagrams

### Before fix: time travel leaves no fork

```
Original execution:

  C0 (start) --> C1 (step_a) --> C2 (ask_1 interrupt) --> C3 (resume) --> C4 (ask_2 interrupt) --> C5 (done)

Time travel to C2 (subgraph config):

  Replay runs... hits interrupt... no new checkpoint written.
  Parent "latest" is still C5.

  Command(resume="new_answer"):
    Loads C5 (wrong!) instead of the replayed C2 state.
```

### After fix: time travel creates a fork

```
Original execution:

  C0 --> C1 --> C2 --> C3 --> C4 --> C5 (done)

Time travel to C2 (subgraph config):

  C0 --> C1 --> C2 --> C3 --> C4 --> C5
                  \
                   F1 (fork, source="fork")  <-- new latest

  Command(resume="new_answer"):
    Loads F1 (correct!) --> resumes from the right state.

  After full resume:

  C0 --> C1 --> C2 --> C3 --> C4 --> C5
                  \
                   F1 --> F2 (ask_1 result) --> F3 (ask_2 interrupt) --> F4 (done)
```

### Manual fork via `update_state` (unchanged)

```
  C0 --> C1 --> C2 --> C3
                  \
                   U1 (source="update")  <-- created by update_state()

  This path already worked. The fix skips update/fork sources
  so existing behavior is preserved.
```

## Changes

- **`libs/langgraph/langgraph/pregel/_loop.py`**:
- Extract `is_time_traveling` flag from the existing replay detection
logic for reuse
- Write a fork checkpoint (`source="fork"`) eagerly at the start of a
time-travel tick, before execution begins
- Clear stale `INTERRUPT` pending writes when creating the fork (they
reference old task IDs that won't match the new checkpoint)
- Unify subgraph replay ID resolution: check `source in ("update",
"fork")` instead of a separate `is_time_traveling` condition, since the
new fork checkpoint now has `source="fork"`
- **`libs/langgraph/tests/test_time_travel.py`** and
**`test_time_travel_async.py`**: Added 4 new test cases (sync + async):
- `test_replay_from_before_interrupt_then_resume` — replays from a
checkpoint before an interrupt, resumes with a new answer, and verifies
the full checkpoint history (source, next, values) at each stage
- `test_subgraph_time_travel_resume_from_first_interrupt` — time-travels
to a subgraph's first interrupt, resumes both interrupts with new
answers, and verifies the fork creates a new branch while preserving the
original
- `test_subgraph_time_travel_resume_from_second_interrupt` —
time-travels to a subgraph's second interrupt, resumes with a new
answer, and verifies the first interrupt's original answer is preserved
- `test_subgraph_time_travel_checkpoint_pattern` — verifies the fork
checkpoint branches from the correct replay point and that the full
checkpoint tree is correct after resume
- **`libs/langgraph/tests/test_pregel.py`** /
**`test_pregel_async.py`**: Updated existing
`test_weather_subgraph_state` to account for the new fork checkpoint
appearing in history (history length increases by 1)
2026-04-16 08:29:48 -04:00
hari-dhanushkodiandGitHub 4d64227c13 chore: start tracking cli deploy source (#7520)
Fixes #

<!-- Replace everything above this line with a 1-2 sentence description
of your change. Keep the "Fixes #xx" keyword and update the issue
number. -->

Read the full contributing guidelines:
https://docs.langchain.com/oss/python/contributing/overview

> **All contributions must be in English.** See the [language
policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).

If you paste a large clearly AI generated description here your PR may
be IGNORED or CLOSED!

Thank you for contributing to LangGraph! Follow these steps to have your
pull request considered as ready for review.

1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION

    - feat(langgraph): add multi-tenant support
- Allowed TYPE and SCOPE values:
https://github.com/langchain-ai/langgraph/blob/main/.github/workflows/pr_lint.yml#L19-L43

2. PR description:

  - Write 1-2 sentences summarizing the change.
- The `Fixes #xx` line at the top is **required** for external
contributions — update the issue number and keep the keyword. This links
your PR to the approved issue and auto-closes it on merge.
  - If there are any breaking changes, please clearly describe them.
- If this PR depends on another PR being merged first, please include
"Depends on #PR_NUMBER" in the description.

3. Run `make format`, `make lint` and `make test` from the root of the
package(s) you've modified.

  - We will not consider a PR unless these three are passing in CI.

4. How did you verify your code works?

Additional guidelines:

- All external PRs must link to an issue or discussion where a solution
has been approved by a maintainer, and you must be assigned to that
issue. PRs without prior approval will be closed.
- PRs should not touch more than one package unless absolutely
necessary.
- Do not update the `uv.lock` files or add dependencies to
`pyproject.toml` files (even optional ones) unless you have explicit
permission to do so by a maintainer.

## Social handles (optional)
<!-- If you'd like a shoutout on release, add your socials below -->
Twitter: @
LinkedIn: https://linkedin.com/in/
2026-04-16 08:24:47 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6bcac5d72e chore(deps): bump langsmith from 0.6.4 to 0.7.31 in /libs/prebuilt (#7530)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.6.4 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.6.4...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.6.4&new-version=0.7.31)](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-15 19:37:47 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6177c4311b chore(deps): bump langsmith from 0.6.4 to 0.7.31 in /libs/checkpoint (#7525)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.6.4 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.6.4...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.6.4&new-version=0.7.31)](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-15 19:37:35 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
303769904b chore(deps): bump langsmith from 0.7.20 to 0.7.31 in /libs/sdk-py (#7528)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.7.20 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.20...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.7.20&new-version=0.7.31)](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-15 19:37:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cee7dcd523 chore(deps): bump langsmith from 0.7.26 to 0.7.31 in /libs/cli (#7529)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.7.26 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.26...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.7.26&new-version=0.7.31)](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-15 19:37:13 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3413723e5a chore(deps): bump langsmith from 0.6.4 to 0.7.31 in /libs/checkpoint-postgres (#7527)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.6.4 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.6.4...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.6.4&new-version=0.7.31)](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-15 19:37:03 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
07252d2cda chore(deps): bump langsmith from 0.6.4 to 0.7.31 in /libs/langgraph (#7526)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.6.4 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.6.4...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.6.4&new-version=0.7.31)](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-15 19:36:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47fd42abb2 chore(deps): bump langsmith from 0.6.4 to 0.7.31 in /libs/checkpoint-sqlite (#7524)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.6.4 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.6.4...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.6.4&new-version=0.7.31)](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-15 19:36:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
554b2db1f8 chore(deps): bump langsmith from 0.7.3 to 0.7.31 in /libs/checkpoint-conformance (#7523)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.7.3 to 0.7.31.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.3...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=uv&previous-version=0.7.3&new-version=0.7.31)](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-15 19:36:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
93a144b404 chore(deps): bump the uv group across 2 directories with 1 update (#7531)
Bumps the uv group with 1 update in the /libs/cli/uv-examples/monorepo
directory: [langsmith](https://github.com/langchain-ai/langsmith-sdk).
Bumps the uv group with 1 update in the /libs/cli/uv-examples/simple
directory: [langsmith](https://github.com/langchain-ai/langsmith-sdk).

Updates `langsmith` from 0.7.26 to 0.7.31
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.26...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />

Updates `langsmith` from 0.7.26 to 0.7.31
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langsmith-sdk/releases">langsmith's
releases</a>.</em></p>
<blockquote>
<h2>v0.7.31</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(deps-dev): bump langchain-core from 1.2.23 to 1.2.28 in
/python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2692">langchain-ai/langsmith-sdk#2692</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.82.0 to
0.84.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2684">langchain-ai/langsmith-sdk#2684</a></li>
<li>chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /python by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2693">langchain-ai/langsmith-sdk#2693</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.84.0 to
0.85.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2700">langchain-ai/langsmith-sdk#2700</a></li>
<li>feat(py): Tag OpenAI Agent Python SDK runs with ls_agent_type by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2699">langchain-ai/langsmith-sdk#2699</a></li>
<li>feat(js): Adds ls_agent_type metadata to AI SDK runs by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2701">langchain-ai/langsmith-sdk#2701</a></li>
<li>chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to
4.67.3.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2710">langchain-ai/langsmith-sdk#2710</a></li>
<li>chore(deps): bump pnpm/action-setup from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2705">langchain-ai/langsmith-sdk#2705</a></li>
<li>chore(deps): bump the py-minor-and-patch group across 1 directory
with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2711">langchain-ai/langsmith-sdk#2711</a></li>
<li>chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2702">langchain-ai/langsmith-sdk#2702</a></li>
<li>chore(deps): bump actions/github-script from 8 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2706">langchain-ai/langsmith-sdk#2706</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2712">langchain-ai/langsmith-sdk#2712</a></li>
<li>chore(deps-dev): bump types-psutil from 7.2.2.20260130 to
7.2.2.20260408 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2709">langchain-ai/langsmith-sdk#2709</a></li>
<li>chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2708">langchain-ai/langsmith-sdk#2708</a></li>
<li>feat: Filter kwargs from new token events by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2714">langchain-ai/langsmith-sdk#2714</a></li>
<li>release(py): 0.7.31 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2716">langchain-ai/langsmith-sdk#2716</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.30...v0.7.31</a></p>
<h2>v0.7.30</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(python): add service feature to sandbox by <a
href="https://github.com/DanielKneipp"><code>@​DanielKneipp</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2665">langchain-ai/langsmith-sdk#2665</a></li>
<li>fix(js): Fix prototype pollution bug in anonymizers by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2690">langchain-ai/langsmith-sdk#2690</a></li>
<li>release(js): 0.5.18 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2691">langchain-ai/langsmith-sdk#2691</a></li>
<li>chore(js/sandbox): suppress warning log by <a
href="https://github.com/hntrl"><code>@​hntrl</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2694">langchain-ai/langsmith-sdk#2694</a></li>
<li>feat(js): Add metadata to Claude Agent SDK JS tracing by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2695">langchain-ai/langsmith-sdk#2695</a></li>
<li>fix(py): Fix run tree memory leak by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2696">langchain-ai/langsmith-sdk#2696</a></li>
<li>release(py): 0.7.30 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2698">langchain-ai/langsmith-sdk#2698</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.29...v0.7.30</a></p>
<h2>v0.7.29</h2>
<h2>What's Changed</h2>
<ul>
<li>release(js): 0.5.17 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2681">langchain-ai/langsmith-sdk#2681</a></li>
<li>feat(py): Fix race condition around Claude Agent SDK instrumentation
by <a href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a>
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2685">langchain-ai/langsmith-sdk#2685</a></li>
<li>release(py): 0.7.29 by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2686">langchain-ai/langsmith-sdk#2686</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29">https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.28...v0.7.29</a></p>
<h2>v0.7.28</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(py): Support subagent tracing in Claude Agents SDK, fix usage
and duplicate messages by <a
href="https://github.com/jacoblee93"><code>@​jacoblee93</code></a> in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2670">langchain-ai/langsmith-sdk#2670</a></li>
<li>chore(deps-dev): bump the py-minor-and-patch group across 1
directory with 11 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2677">langchain-ai/langsmith-sdk#2677</a></li>
<li>chore(deps-dev): bump the js-minor-and-patch group across 1
directory with 8 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2667">langchain-ai/langsmith-sdk#2667</a></li>
<li>chore(deps): bump pnpm/action-setup from 4 to 5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/pull/2658">langchain-ai/langsmith-sdk#2658</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/c434999d05c00334efeba88b8bbd2de9f3afbef6"><code>c434999</code></a>
release(py): 0.7.31 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2716">#2716</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/47d7c4a783333e716395d802e7632f1f1b4744d3"><code>47d7c4a</code></a>
feat: Filter kwargs from new token events (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2714">#2714</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/3c57445b543c9a2f86db52024ea2c998bfc2ffab"><code>3c57445</code></a>
chore(deps-dev): bump rich from 14.3.3 to 15.0.0 in /python (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2708">#2708</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/2be6cd01a2b6e35e811488d3561e7b0b57b06f63"><code>2be6cd0</code></a>
chore(deps-dev): bump types-psutil from 7.2.2.20260130 to 7.2.2.20260408
in /...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/b8b6ca32d43c919c07a4e13c99a83bcaab8accb0"><code>b8b6ca3</code></a>
chore(deps-dev): bump the js-minor-and-patch group across 1 directory
with 7 ...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9897cb33da7698291637f268edd833ca3e1adde6"><code>9897cb3</code></a>
chore(deps): bump actions/github-script from 8 to 9 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2706">#2706</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/572c0184285747e027a796e03ea6c9ba171e09a6"><code>572c018</code></a>
chore(deps-dev): bump <code>@​anthropic-ai/sdk</code> from 0.85.0 to
0.86.0 in /js (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2702">#2702</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/57447524c88b6bba2775161aa449da32fb8e5c42"><code>5744752</code></a>
chore(deps): bump the py-minor-and-patch group across 1 directory with
10 upd...</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/960cae7f490e9ccbe428e6b56c8047bdb7b942a5"><code>960cae7</code></a>
chore(deps): bump pnpm/action-setup from 5 to 6 (<a
href="https://redirect.github.com/langchain-ai/langsmith-sdk/issues/2705">#2705</a>)</li>
<li><a
href="https://github.com/langchain-ai/langsmith-sdk/commit/9370e7670abf7f8f9a36fbb72250bcfd2f91e7c6"><code>9370e76</code></a>
chore(deps-dev): bump types-tqdm from 4.67.3.20260303 to 4.67.3.20260408
in /...</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langsmith-sdk/compare/v0.7.26...v0.7.31">compare
view</a></li>
</ul>
</details>
<br />


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 <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
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-15 19:36:05 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2f4611db8f chore(deps): bump langsmith from 0.5.18 to 0.5.20 in /libs/cli/js-examples (#7522)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.5.18 to 0.5.20.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/langchain-ai/langsmith-sdk/commits">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=npm_and_yarn&previous-version=0.5.18&new-version=0.5.20)](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-15 19:21:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20c5d36efe chore(deps): bump langsmith from 0.5.18 to 0.5.20 in /libs/cli/js-monorepo-example (#7521)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.5.18 to 0.5.20.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/langchain-ai/langsmith-sdk/commits">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=npm_and_yarn&previous-version=0.5.18&new-version=0.5.20)](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-15 19:21:00 -07:00
25470ea435 release(checkpoint): 4.0.2 (#7518)
Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
2026-04-15 20:52:57 +00:00
7fa49bd550 docs: document LANGGRAPH_STRICT_MSGPACK for checkpoint security (#7517)
## Summary

- Add `LANGGRAPH_STRICT_MSGPACK=true` guidance to `JsonPlusSerializer`
docstring and inline comments
- Update the warning message emitted for unregistered types to mention
the env var
- Add module docstring to `_msgpack.py` explaining the safety controls
- Add Security sections to checkpoint, checkpoint-postgres, and
checkpoint-sqlite READMEs

## Context

Multiple security advisories have reported the same msgpack
deserialization pattern (`ext_hook` → `importlib.import_module` →
`getattr` → call). The underlying behavior is documented in the repo's
threat model as T1, but the `LANGGRAPH_STRICT_MSGPACK` env var that
mitigates it is not surfaced in user-facing docs, docstrings, or warning
messages. This PR closes that gap.

## Test plan

- [x] Verify READMEs render correctly on GitHub (callout boxes use `>
[!IMPORTANT]` syntax)
- [x] Verify `JsonPlusSerializer` docstring renders in IDE tooltips
- [x] Confirm warning message format: `LANGGRAPH_STRICT_MSGPACK=true
PYTHON_CMD 2>&1 | grep -i strict`

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:05:55 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6719d34023 chore(deps): bump pytest from 9.0.2 to 9.0.3 in /libs/checkpoint-sqlite (#7502)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 17:39:56 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
96843788d0 chore(deps): bump langchain-core from 1.2.27 to 1.2.28 in /libs/cli (#7450)
Bumps [langchain-core](https://github.com/langchain-ai/langchain) from
1.2.27 to 1.2.28.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li>See full diff in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.27...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langchain-core&package-manager=uv&previous-version=1.2.27&new-version=1.2.28)](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>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
2026-04-14 10:39:03 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ba5e3c4a9b chore(deps): bump pytest from 9.0.2 to 9.0.3 in /libs/checkpoint (#7506)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=9.0.2&new-version=9.0.3)](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-14 10:38:29 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9e9783b156 chore(deps): bump pytest from 9.0.2 to 9.0.3 in /libs/prebuilt (#7505)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=9.0.2&new-version=9.0.3)](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-14 10:38:19 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c27d103e04 chore(deps): bump pytest from 9.0.2 to 9.0.3 in /libs/checkpoint-conformance (#7508)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=9.0.2&new-version=9.0.3)](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-14 10:35:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d189f6551e chore(deps): bump pytest from 9.0.2 to 9.0.3 in /libs/langgraph (#7507)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=9.0.2&new-version=9.0.3)](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-14 10:34:46 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1dd9adf833 chore(deps): bump pytest from 9.0.2 to 9.0.3 in /libs/checkpoint-postgres (#7503)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=9.0.2&new-version=9.0.3)](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-14 10:34:34 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
92c66ca997 chore(deps): bump langchain-core from 1.2.22 to 1.2.28 in /libs/checkpoint-sqlite (#7451)
Bumps [langchain-core](https://github.com/langchain-ai/langchain) from
1.2.22 to 1.2.28.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
<h2>langchain-core==1.2.27</h2>
<p>Changes since langchain-core==1.2.26</p>
<p>release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)
chore: add comment explaining <code>pygments&gt;=2.20.0</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36570">#36570</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>.</p>
<h2>langchain-core==1.2.26</h2>
<p>Changes since langchain-core==1.2.25</p>
<p>release(core): 1.2.26 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36511">#36511</a>)
fix(core): add init validator and serialization mappings for Bedrock
models (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34510">#34510</a>)
feat(core): add <code>ChatBaseten</code> to serializable mapping (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36510">#36510</a>)
chore(core): drop <code>gpt-3.5-turbo</code> from docstrings (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36497">#36497</a>)
fix(core): correct parameter names in filter_messages docstring example
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36462">#36462</a>)</p>
<h2>langchain-core==1.2.25</h2>
<p>Changes since langchain-core==1.2.24</p>
<p>release(core): 1.2.25 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36473">#36473</a>)
fix(core): harden check for txt files in deprecated prompt loading
functions (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue resolved in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>.</p>
<h2>langchain-core==1.2.24</h2>
<p>Changes since langchain-core==1.2.23</p>
<p>release(core): 1.2.24 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36434">#36434</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
fix(core): add &quot;computer&quot; to _WellKnownOpenAITools (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36261">#36261</a>)</p>
<h2>langchain-core==1.2.23</h2>
<p>Changes since langchain-core==1.2.22</p>
<p>release(core): 1.2.23 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36323">#36323</a>)
revert: Revert &quot;fix(core): trace invocation params in
metadata&quot; (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36322">#36322</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/core (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36243">#36243</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/64864041168606535dfbd39055c0dca3dd61b5ba"><code>6486404</code></a>
release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7629c747260cbaed7ca55466d5b9e1b520a7de77"><code>7629c74</code></a>
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/ce21bf469d7493f4716bc30feb15a5b3f16ebe1e"><code>ce21bf4</code></a>
ci: convert working-directory to validated dropdown (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36575">#36575</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b8698eacbd2960c7e3195018f42992bf2c9d69c7"><code>b8698ea</code></a>
release(ollama): 1.1.0 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36574">#36574</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/3beba77e2e23d498fda07f9b8d6ba00aabfaf69f"><code>3beba77</code></a>
feat(ollama): support <code>response_format</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34612">#34612</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.22...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langchain-core&package-manager=uv&previous-version=1.2.22&new-version=1.2.28)](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>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
2026-04-14 10:32:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a7356edf8a chore(deps): bump langsmith from 0.5.4 to 0.5.18 in /libs/cli/js-examples (#7474)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.5.4 to 0.5.18.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/langchain-ai/langsmith-sdk/commits">compare
view</a></li>
</ul>
</details>
<details>
<summary>Install script changes</summary>
<p>This version modifies <code>prepublish</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=npm_and_yarn&previous-version=0.5.4&new-version=0.5.18)](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-14 10:25:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
354dceaac7 chore(deps-dev): bump pytest from 9.0.2 to 9.0.3 in /libs/sdk-py (#7504)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to
9.0.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/pytest-dev/pytest/releases">pytest's
releases</a>.</em></p>
<blockquote>
<h2>9.0.3</h2>
<h1>pytest 9.0.3 (2026-04-07)</h1>
<h2>Bug fixes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12444">#12444</a>:
Fixed <code>pytest.approx</code> which now correctly takes into account
<code>~collections.abc.Mapping</code> keys order to compare them.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13634">#13634</a>:
Blocking a <code>conftest.py</code> file using the <code>-p no:</code>
option is now explicitly disallowed.</p>
<p>Previously this resulted in an internal assertion failure during
plugin loading.</p>
<p>Pytest now raises a clear <code>UsageError</code> explaining that
conftest files are not plugins and cannot be disabled via
<code>-p</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13734">#13734</a>:
Fixed crash when a test raises an exceptiongroup with
<code>__tracebackhide__ = True</code>.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14195">#14195</a>:
Fixed an issue where non-string messages passed to <!-- raw HTML omitted
-->unittest.TestCase.subTest()<!-- raw HTML omitted --> were not
printed.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>:
Fixed use of insecure temporary directory (CVE-2025-71176).</p>
</li>
</ul>
<h2>Improved documentation</h2>
<ul>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13388">#13388</a>:
Clarified documentation for <code>-p</code> vs
<code>PYTEST_PLUGINS</code> plugin loading and fixed an incorrect
<code>-p</code> example.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/13731">#13731</a>:
Clarified that capture fixtures (e.g. <code>capsys</code> and
<code>capfd</code>) take precedence over the <code>-s</code> /
<code>--capture=no</code> command-line options in <code>Accessing
captured output from a test function
&lt;accessing-captured-output&gt;</code>.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14088">#14088</a>:
Clarified that the default <code>pytest_collection</code> hook sets
<code>session.items</code> before it calls
<code>pytest_collection_finish</code>, not after.</li>
<li><a
href="https://redirect.github.com/pytest-dev/pytest/issues/14255">#14255</a>:
TOML integer log levels must be quoted: Updating reference
documentation.</li>
</ul>
<h2>Contributor-facing changes</h2>
<ul>
<li>
<p><a
href="https://redirect.github.com/pytest-dev/pytest/issues/12689">#12689</a>:
The test reports are now published to Codecov from GitHub Actions.
The test statistics is visible <a
href="https://app.codecov.io/gh/pytest-dev/pytest/tests">on the web
interface</a>.</p>
<p>-- by <code>aleguy02</code></p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pytest-dev/pytest/commit/a7d58d7a21b78581e636bbbdea13c66ad1657c1e"><code>a7d58d7</code></a>
Prepare release version 9.0.3</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/089d98199c253d8f89a040243bc4f2aa6cd5ab22"><code>089d981</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14366">#14366</a>
from bluetech/revert-14193-backport</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/8127eaf4ab7f6b2fdd0dc1b38343ec97aeef05ac"><code>8127eaf</code></a>
Revert &quot;Fix: assertrepr_compare respects dict insertion order (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14050">#14050</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14193">#14193</a>)&quot;</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/99a7e6029e7a6e8d53e5df114b1346e035370241"><code>99a7e60</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14363">#14363</a>
from pytest-dev/patchback/backports/9.0.x/95d8423bd...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/ddee02a578da30dd43aedc39c1c1f1aaadfcee95"><code>ddee02a</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14343">#14343</a>
from bluetech/cve-2025-71176-simple</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/74eac6916fee34726cb194f16c516e96fbd29619"><code>74eac69</code></a>
doc: Update training info (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14298">#14298</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14301">#14301</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/f92dee777cfdb77d1c43633d02766ddf1f07c869"><code>f92dee7</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14267">#14267</a>
from pytest-dev/patchback/backports/9.0.x/d6fa26c62...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/7ee58acc8777c31ac6cf388d01addf5a414a7439"><code>7ee58ac</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/12378">#12378</a>
from Pierre-Sassoulas/fix-implicit-str-concat-and-d...</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/37da870d37e3a2f5177cae075c7b9ae279432bf8"><code>37da870</code></a>
Merge pull request <a
href="https://redirect.github.com/pytest-dev/pytest/issues/14259">#14259</a>
from mitre88/patch-4 (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14268">#14268</a>)</li>
<li><a
href="https://github.com/pytest-dev/pytest/commit/c34bfa3b7acb65b594707c714f1d8461b0304eed"><code>c34bfa3</code></a>
Add explanation for string context diffs (<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14257">#14257</a>)
(<a
href="https://redirect.github.com/pytest-dev/pytest/issues/14266">#14266</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pytest&package-manager=uv&previous-version=9.0.2&new-version=9.0.3)](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-14 10:24:27 -07:00
Eugene YurtsevandGitHub 2ff294af77 release(langgraph): 1.1.7a2 (#7511)
Release 1.1.7a2
2026-04-14 16:55:35 +00:00
4c67f84016 chore: allow passing some metadata only for tracing purposes (#7383)
Allows us to put some more information for tracing purposes (e.g.,
ls_integration) without dumping it into the streaming APIs (good for
performance)
Move some other metadata into tracing only since it's not needed in
streaming APIs

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2026-04-14 09:58:24 -04:00
open-swe[bot]GitHubopen-swe[bot] <open-swe@users.noreply.github.com>Sydney Runkle
2c98c59fca fix: populate assistant_id from config configurable instead of metadata (#7468)
## Description
The `_build_server_info` function was reading `assistant_id` and
`graph_id` from `config["metadata"]`, but the server puts these values
in `config["configurable"]`. This updates the source to read from
`configurable` consistently.

## Test Plan
- [ ] Verify `server_info.assistant_id` and `server_info.graph_id` are
correctly populated from `config["configurable"]`

_Opened collaboratively by Sydney Runkle and open-swe._

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2026-04-13 09:08:09 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d27d4b2d98 chore(deps): bump langsmith from 0.5.4 to 0.5.18 in /libs/cli/js-monorepo-example (#7475)
Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from
0.5.4 to 0.5.18.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/langchain-ai/langsmith-sdk/commits">compare
view</a></li>
</ul>
</details>
<details>
<summary>Install script changes</summary>
<p>This version modifies <code>prepublish</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langsmith&package-manager=npm_and_yarn&previous-version=0.5.4&new-version=0.5.18)](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-10 14:29:04 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
742d165acb chore(deps): bump uv from 0.11.3 to 0.11.6 in /libs/cli (#7472)
Bumps [uv](https://github.com/astral-sh/uv) from 0.11.3 to 0.11.6.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/releases">uv's
releases</a>.</em></p>
<blockquote>
<h2>0.11.6</h2>
<h2>Release Notes</h2>
<p>Released on 2026-04-09.</p>
<p>This release resolves a low severity security advisory in which
wheels with malformed RECORD entries could delete arbitrary files on
uninstall. See <a
href="https://github.com/astral-sh/uv/security/advisories/GHSA-pjjw-68hj-v9mw">GHSA-pjjw-68hj-v9mw</a>
for details.</p>
<h3>Bug fixes</h3>
<ul>
<li>Do not remove files outside the venv on uninstall (<a
href="https://redirect.github.com/astral-sh/uv/pull/18942">#18942</a>)</li>
<li>Validate and heal wheel <code>RECORD</code> during installation (<a
href="https://redirect.github.com/astral-sh/uv/pull/18943">#18943</a>)</li>
<li>Avoid <code>uv cache clean</code> errors due to Win32 path
normalization (<a
href="https://redirect.github.com/astral-sh/uv/pull/18856">#18856</a>)</li>
</ul>
<h2>Install uv 0.11.6</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-installer.ps1
| iex&quot;
</code></pre>
<h2>Download uv 0.11.6</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-apple-darwin.tar.gz">uv-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-apple-darwin.tar.gz">uv-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-pc-windows-msvc.zip">uv-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-i686-pc-windows-msvc.zip">uv-i686-pc-windows-msvc.zip</a></td>
<td>x86 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-pc-windows-msvc.zip">uv-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-unknown-linux-gnu.tar.gz">uv-aarch64-unknown-linux-gnu.tar.gz</a></td>
<td>ARM64 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-i686-unknown-linux-gnu.tar.gz">uv-i686-unknown-linux-gnu.tar.gz</a></td>
<td>x86 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-i686-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-powerpc64le-unknown-linux-gnu.tar.gz">uv-powerpc64le-unknown-linux-gnu.tar.gz</a></td>
<td>PPC64LE Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-powerpc64le-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-riscv64gc-unknown-linux-gnu.tar.gz">uv-riscv64gc-unknown-linux-gnu.tar.gz</a></td>
<td>RISCV Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-riscv64gc-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-s390x-unknown-linux-gnu.tar.gz">uv-s390x-unknown-linux-gnu.tar.gz</a></td>
<td>S390x Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-s390x-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-unknown-linux-gnu.tar.gz">uv-x86_64-unknown-linux-gnu.tar.gz</a></td>
<td>x64 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-armv7-unknown-linux-gnueabihf.tar.gz">uv-armv7-unknown-linux-gnueabihf.tar.gz</a></td>
<td>ARMv7 Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-armv7-unknown-linux-gnueabihf.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-unknown-linux-musl.tar.gz">uv-aarch64-unknown-linux-musl.tar.gz</a></td>
<td>ARM64 MUSL Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-aarch64-unknown-linux-musl.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-i686-unknown-linux-musl.tar.gz">uv-i686-unknown-linux-musl.tar.gz</a></td>
<td>x86 MUSL Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-i686-unknown-linux-musl.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-riscv64gc-unknown-linux-musl.tar.gz">uv-riscv64gc-unknown-linux-musl.tar.gz</a></td>
<td>RISCV MUSL Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-riscv64gc-unknown-linux-musl.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-unknown-linux-musl.tar.gz">uv-x86_64-unknown-linux-musl.tar.gz</a></td>
<td>x64 MUSL Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-x86_64-unknown-linux-musl.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-arm-unknown-linux-musleabihf.tar.gz">uv-arm-unknown-linux-musleabihf.tar.gz</a></td>
<td>ARMv6 MUSL Linux (Hardfloat)</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-arm-unknown-linux-musleabihf.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-armv7-unknown-linux-musleabihf.tar.gz">uv-armv7-unknown-linux-musleabihf.tar.gz</a></td>
<td>ARMv7 MUSL Linux</td>
<td><a
href="https://releases.astral.sh/github/uv/releases/download/0.11.6/uv-armv7-unknown-linux-musleabihf.tar.gz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/uv/blob/main/CHANGELOG.md">uv's
changelog</a>.</em></p>
<blockquote>
<h2>0.11.6</h2>
<p>Released on 2026-04-09.</p>
<p>This release resolves a low severity security advisory in which
wheels with malformed RECORD entries could delete arbitrary files on
uninstall. See <a
href="https://github.com/astral-sh/uv/security/advisories/GHSA-pjjw-68hj-v9mw">GHSA-pjjw-68hj-v9mw</a>
for details.</p>
<h3>Bug fixes</h3>
<ul>
<li>Do not remove files outside the venv on uninstall (<a
href="https://redirect.github.com/astral-sh/uv/pull/18942">#18942</a>)</li>
<li>Validate and heal wheel <code>RECORD</code> during installation (<a
href="https://redirect.github.com/astral-sh/uv/pull/18943">#18943</a>)</li>
<li>Avoid <code>uv cache clean</code> errors due to Win32 path
normalization (<a
href="https://redirect.github.com/astral-sh/uv/pull/18856">#18856</a>)</li>
</ul>
<h2>0.11.5</h2>
<p>Released on 2026-04-08.</p>
<h3>Python</h3>
<ul>
<li>Add CPython 3.13.13, 3.14.4, and 3.15.0a8 (<a
href="https://redirect.github.com/astral-sh/uv/pull/18908">#18908</a>)</li>
</ul>
<h3>Enhancements</h3>
<ul>
<li>Fix <code>build_system.requires</code> error message (<a
href="https://redirect.github.com/astral-sh/uv/pull/18911">#18911</a>)</li>
<li>Remove trailing path separators in path normalization (<a
href="https://redirect.github.com/astral-sh/uv/pull/18915">#18915</a>)</li>
<li>Improve error messages for unsupported or invalid TLS certificates
(<a
href="https://redirect.github.com/astral-sh/uv/pull/18924">#18924</a>)</li>
</ul>
<h3>Preview features</h3>
<ul>
<li>Add <code>exclude-newer</code> to <code>[[tool.uv.index]]</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/18839">#18839</a>)</li>
<li><code>uv audit</code>: add context/warnings for ignored
vulnerabilities (<a
href="https://redirect.github.com/astral-sh/uv/pull/18905">#18905</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Normalize persisted fork markers before lock equality checks (<a
href="https://redirect.github.com/astral-sh/uv/pull/18612">#18612</a>)</li>
<li>Clear junction properly when uninstalling Python versions on Windows
(<a
href="https://redirect.github.com/astral-sh/uv/pull/18815">#18815</a>)</li>
<li>Report error cleanly instead of panicking on TLS certificate error
(<a
href="https://redirect.github.com/astral-sh/uv/pull/18904">#18904</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Remove the legacy <code>PIP_COMPATIBILITY.md</code> redirect file
(<a
href="https://redirect.github.com/astral-sh/uv/pull/18928">#18928</a>)</li>
<li>Fix <code>uv init example-bare --bare</code> examples (<a
href="https://redirect.github.com/astral-sh/uv/pull/18822">#18822</a>,
<a
href="https://redirect.github.com/astral-sh/uv/pull/18925">#18925</a>)</li>
</ul>
<h2>0.11.4</h2>
<p>Released on 2026-04-07.</p>
<h3>Enhancements</h3>
<ul>
<li>Add support for <code>--upgrade-group</code> (<a
href="https://redirect.github.com/astral-sh/uv/pull/18266">#18266</a>)</li>
<li>Merge repeated archive URL hashes by version ID (<a
href="https://redirect.github.com/astral-sh/uv/pull/18841">#18841</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/uv/commit/65950801cc3c609b65be34938bb407ab6e30a9fe"><code>6595080</code></a>
Bump version to 0.11.6 (<a
href="https://redirect.github.com/astral-sh/uv/issues/18948">#18948</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/7983c7a5bef236fd8a04580fcedae7bd5bde4cdb"><code>7983c7a</code></a>
Validate and heal RECORD during installation (<a
href="https://redirect.github.com/astral-sh/uv/issues/18943">#18943</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/b38439bfc731d5281e933656ce2e5b910da037b0"><code>b38439b</code></a>
Avoid <code>uv cache clean</code> errors due to Win32 path normalization
(<a
href="https://redirect.github.com/astral-sh/uv/issues/18856">#18856</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/a0e461ac44851f9a0f6e8974733e77d46f7a9ea9"><code>a0e461a</code></a>
Do not remove files outside the venv on uninstall (<a
href="https://redirect.github.com/astral-sh/uv/issues/18942">#18942</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/95eaa68c8df627eb915bc355831fd7d169d91fe3"><code>95eaa68</code></a>
Bump version to 0.11.5 (<a
href="https://redirect.github.com/astral-sh/uv/issues/18930">#18930</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/f6d67d57c1a9f17f7ab233654b55e061eb4bfd10"><code>f6d67d5</code></a>
Improve certificate loading error messages (<a
href="https://redirect.github.com/astral-sh/uv/issues/18924">#18924</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/39b83c30e0cdaed833e88564878376f9361987d2"><code>39b83c3</code></a>
Add <code>exclude-newer</code> to <code>[[tool.uv.index]]</code> (<a
href="https://redirect.github.com/astral-sh/uv/issues/18839">#18839</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/7924ba5b1419345dc5b9a9a16e6bcba2b59a41a6"><code>7924ba5</code></a>
uv audit: add context/warnings for ignored vulnerabilities (<a
href="https://redirect.github.com/astral-sh/uv/issues/18905">#18905</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/a352ce01eab5f19bbd5929f2a5f346187552ee7c"><code>a352ce0</code></a>
Remove the legacy PIP_COMPATIBILITY.md redirect file (<a
href="https://redirect.github.com/astral-sh/uv/issues/18928">#18928</a>)</li>
<li><a
href="https://github.com/astral-sh/uv/commit/33b633891181f768568bfc3196039d368417fe98"><code>33b6338</code></a>
Normalize persisted fork markers before lock equality checks (<a
href="https://redirect.github.com/astral-sh/uv/issues/18612">#18612</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/uv/compare/0.11.3...0.11.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uv&package-manager=uv&previous-version=0.11.3&new-version=0.11.6)](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-10 14:28:46 -07:00
Eugene YurtsevandGitHub b442bf802a release(langgraph): 1.1.7a1 (#7476)
Adding graph life cycle callbacks
2026-04-10 21:12:03 +00:00
Eugene YurtsevandGitHub bede0b7acf test(langgraph): use monotonic clock in flaky streaming test (#7477)
Switches test_sync_streaming_with_functional_api to time.monotonic() for
both emitted task timestamps and observed arrival times so the assertion
is based on a monotonic clock instead of wall time. This makes the
streaming timing check less flaky on systems where time.time() can jump
or lack sufficient precision.

Created with [Deep Agents
CLI](https://docs.langchain.com/oss/python/deepagents/cli/overview)
using gpt-5.4 (provider: openai).
2026-04-10 21:05:37 +00:00
3a5b5c9821 feat(langgraph): add graph lifecycle callback handlers (#7429)
## Summary

This change adds first-class graph lifecycle callbacks to LangGraph so
interrupt and resume transitions can be observed without overloading the
existing LangChain custom event system. It introduces a dedicated graph
callback manager and wires lifecycle emission through Pregel execution
in both sync and async paths.

## Changes

- **`libs/langgraph/langgraph/callbacks.py`**: Adds
`GraphCallbackHandler` and `GraphCallbackManager` (built on LangChain
base callback classes), plus config plumbing via `graph_callbacks` and
`get_graph_callback_manager_for_config`.
- **`libs/langgraph/langgraph/pregel/_loop.py`**: Introduces
`GraphLifecycleEvent` and records lifecycle transitions (`resume`,
`interrupt`) into an internal FIFO queue with
`shift_graph_lifecycle_event()`.
- **`libs/langgraph/langgraph/pregel/main.py`**: Resolves graph callback
manager from config and drains lifecycle events while loop execution
progresses, dispatching `on_resume` and `on_interrupt` consistently in
sync and async runtimes.
- **`libs/langgraph/tests/test_graph_callbacks.py`**: Adds sync and
async coverage verifying lifecycle callbacks fire correctly and remain
distinct from LangChain `on_custom_event` handlers.

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2026-04-10 20:43:48 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
1142ebf921 chore(deps): bump langchain-core from 1.2.23 to 1.2.28 in /libs/checkpoint (#7453)
Bumps [langchain-core](https://github.com/langchain-ai/langchain) from
1.2.23 to 1.2.28.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
<h2>langchain-core==1.2.27</h2>
<p>Changes since langchain-core==1.2.26</p>
<p>release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)
chore: add comment explaining <code>pygments&gt;=2.20.0</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36570">#36570</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>.</p>
<h2>langchain-core==1.2.26</h2>
<p>Changes since langchain-core==1.2.25</p>
<p>release(core): 1.2.26 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36511">#36511</a>)
fix(core): add init validator and serialization mappings for Bedrock
models (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34510">#34510</a>)
feat(core): add <code>ChatBaseten</code> to serializable mapping (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36510">#36510</a>)
chore(core): drop <code>gpt-3.5-turbo</code> from docstrings (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36497">#36497</a>)
fix(core): correct parameter names in filter_messages docstring example
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36462">#36462</a>)</p>
<h2>langchain-core==1.2.25</h2>
<p>Changes since langchain-core==1.2.24</p>
<p>release(core): 1.2.25 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36473">#36473</a>)
fix(core): harden check for txt files in deprecated prompt loading
functions (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue resolved in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>.</p>
<h2>langchain-core==1.2.24</h2>
<p>Changes since langchain-core==1.2.23</p>
<p>release(core): 1.2.24 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36434">#36434</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
fix(core): add &quot;computer&quot; to _WellKnownOpenAITools (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36261">#36261</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/64864041168606535dfbd39055c0dca3dd61b5ba"><code>6486404</code></a>
release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7629c747260cbaed7ca55466d5b9e1b520a7de77"><code>7629c74</code></a>
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/ce21bf469d7493f4716bc30feb15a5b3f16ebe1e"><code>ce21bf4</code></a>
ci: convert working-directory to validated dropdown (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36575">#36575</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b8698eacbd2960c7e3195018f42992bf2c9d69c7"><code>b8698ea</code></a>
release(ollama): 1.1.0 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36574">#36574</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/3beba77e2e23d498fda07f9b8d6ba00aabfaf69f"><code>3beba77</code></a>
feat(ollama): support <code>response_format</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34612">#34612</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.23...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langchain-core&package-manager=uv&previous-version=1.2.23&new-version=1.2.28)](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>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
2026-04-10 00:14:25 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
37b34bdb1e chore(deps): bump langchain-core from 1.2.22 to 1.2.28 in /libs/sdk-py (#7449)
Bumps [langchain-core](https://github.com/langchain-ai/langchain) from
1.2.22 to 1.2.28.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
<h2>langchain-core==1.2.27</h2>
<p>Changes since langchain-core==1.2.26</p>
<p>release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)
chore: add comment explaining <code>pygments&gt;=2.20.0</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36570">#36570</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>.</p>
<h2>langchain-core==1.2.26</h2>
<p>Changes since langchain-core==1.2.25</p>
<p>release(core): 1.2.26 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36511">#36511</a>)
fix(core): add init validator and serialization mappings for Bedrock
models (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34510">#34510</a>)
feat(core): add <code>ChatBaseten</code> to serializable mapping (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36510">#36510</a>)
chore(core): drop <code>gpt-3.5-turbo</code> from docstrings (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36497">#36497</a>)
fix(core): correct parameter names in filter_messages docstring example
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36462">#36462</a>)</p>
<h2>langchain-core==1.2.25</h2>
<p>Changes since langchain-core==1.2.24</p>
<p>release(core): 1.2.25 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36473">#36473</a>)
fix(core): harden check for txt files in deprecated prompt loading
functions (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue resolved in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>.</p>
<h2>langchain-core==1.2.24</h2>
<p>Changes since langchain-core==1.2.23</p>
<p>release(core): 1.2.24 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36434">#36434</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
fix(core): add &quot;computer&quot; to _WellKnownOpenAITools (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36261">#36261</a>)</p>
<h2>langchain-core==1.2.23</h2>
<p>Changes since langchain-core==1.2.22</p>
<p>release(core): 1.2.23 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36323">#36323</a>)
revert: Revert &quot;fix(core): trace invocation params in
metadata&quot; (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36322">#36322</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/core (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36243">#36243</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/64864041168606535dfbd39055c0dca3dd61b5ba"><code>6486404</code></a>
release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7629c747260cbaed7ca55466d5b9e1b520a7de77"><code>7629c74</code></a>
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/ce21bf469d7493f4716bc30feb15a5b3f16ebe1e"><code>ce21bf4</code></a>
ci: convert working-directory to validated dropdown (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36575">#36575</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b8698eacbd2960c7e3195018f42992bf2c9d69c7"><code>b8698ea</code></a>
release(ollama): 1.1.0 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36574">#36574</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/3beba77e2e23d498fda07f9b8d6ba00aabfaf69f"><code>3beba77</code></a>
feat(ollama): support <code>response_format</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34612">#34612</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.22...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langchain-core&package-manager=uv&previous-version=1.2.22&new-version=1.2.28)](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>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
2026-04-09 23:55:19 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
11e8d827eb chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /libs/langgraph (#7457)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.6
to 46.0.7.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's
changelog</a>.</em></p>
<blockquote>
<p>46.0.7 - 2026-04-07</p>
<pre><code>
* **SECURITY ISSUE**: Fixed an issue where non-contiguous buffers could
be
  passed to APIs that accept Python buffers, which could lead to buffer
  overflow. **CVE-2026-39892**
* Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
3.5.6.
<p>.. _v46-0-6:<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pyca/cryptography/commit/622d672e429a7cff836a23c5903683dbec1901f5"><code>622d672</code></a>
46.0.7 release (<a
href="https://redirect.github.com/pyca/cryptography/issues/14602">#14602</a>)</li>
<li>See full diff in <a
href="https://github.com/pyca/cryptography/compare/46.0.6...46.0.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cryptography&package-manager=uv&previous-version=46.0.6&new-version=46.0.7)](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-09 23:54:47 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5fcebdd30a chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /libs/cli (#7456)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.6
to 46.0.7.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's
changelog</a>.</em></p>
<blockquote>
<p>46.0.7 - 2026-04-07</p>
<pre><code>
* **SECURITY ISSUE**: Fixed an issue where non-contiguous buffers could
be
  passed to APIs that accept Python buffers, which could lead to buffer
  overflow. **CVE-2026-39892**
* Updated Windows, macOS, and Linux wheels to be compiled with OpenSSL
3.5.6.
<p>.. _v46-0-6:<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pyca/cryptography/commit/622d672e429a7cff836a23c5903683dbec1901f5"><code>622d672</code></a>
46.0.7 release (<a
href="https://redirect.github.com/pyca/cryptography/issues/14602">#14602</a>)</li>
<li>See full diff in <a
href="https://github.com/pyca/cryptography/compare/46.0.6...46.0.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cryptography&package-manager=uv&previous-version=46.0.6&new-version=46.0.7)](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-09 23:54:28 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
173ef2ff44 chore(deps): bump langchain-core from 1.2.22 to 1.2.28 in /libs/checkpoint-postgres (#7454)
Bumps [langchain-core](https://github.com/langchain-ai/langchain) from
1.2.22 to 1.2.28.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
<h2>langchain-core==1.2.27</h2>
<p>Changes since langchain-core==1.2.26</p>
<p>release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)
chore: add comment explaining <code>pygments&gt;=2.20.0</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36570">#36570</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>.</p>
<h2>langchain-core==1.2.26</h2>
<p>Changes since langchain-core==1.2.25</p>
<p>release(core): 1.2.26 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36511">#36511</a>)
fix(core): add init validator and serialization mappings for Bedrock
models (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34510">#34510</a>)
feat(core): add <code>ChatBaseten</code> to serializable mapping (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36510">#36510</a>)
chore(core): drop <code>gpt-3.5-turbo</code> from docstrings (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36497">#36497</a>)
fix(core): correct parameter names in filter_messages docstring example
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36462">#36462</a>)</p>
<h2>langchain-core==1.2.25</h2>
<p>Changes since langchain-core==1.2.24</p>
<p>release(core): 1.2.25 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36473">#36473</a>)
fix(core): harden check for txt files in deprecated prompt loading
functions (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue resolved in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>.</p>
<h2>langchain-core==1.2.24</h2>
<p>Changes since langchain-core==1.2.23</p>
<p>release(core): 1.2.24 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36434">#36434</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
fix(core): add &quot;computer&quot; to _WellKnownOpenAITools (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36261">#36261</a>)</p>
<h2>langchain-core==1.2.23</h2>
<p>Changes since langchain-core==1.2.22</p>
<p>release(core): 1.2.23 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36323">#36323</a>)
revert: Revert &quot;fix(core): trace invocation params in
metadata&quot; (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36322">#36322</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/core (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36243">#36243</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/64864041168606535dfbd39055c0dca3dd61b5ba"><code>6486404</code></a>
release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7629c747260cbaed7ca55466d5b9e1b520a7de77"><code>7629c74</code></a>
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/ce21bf469d7493f4716bc30feb15a5b3f16ebe1e"><code>ce21bf4</code></a>
ci: convert working-directory to validated dropdown (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36575">#36575</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b8698eacbd2960c7e3195018f42992bf2c9d69c7"><code>b8698ea</code></a>
release(ollama): 1.1.0 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36574">#36574</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/3beba77e2e23d498fda07f9b8d6ba00aabfaf69f"><code>3beba77</code></a>
feat(ollama): support <code>response_format</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34612">#34612</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.22...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langchain-core&package-manager=uv&previous-version=1.2.22&new-version=1.2.28)](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-09 23:54:09 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
98afc106a0 chore(deps): bump the uv group across 2 directories with 1 update (#7458)
Bumps the uv group with 1 update in the /libs/cli/uv-examples/monorepo
directory: [langchain-core](https://github.com/langchain-ai/langchain).
Bumps the uv group with 1 update in the /libs/cli/uv-examples/simple
directory: [langchain-core](https://github.com/langchain-ai/langchain).

Updates `langchain-core` from 1.2.27 to 1.2.28
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li>See full diff in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.27...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />

Updates `langchain-core` from 1.2.27 to 1.2.28
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li>See full diff in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.27...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


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 <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
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>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
2026-04-09 23:51:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
80ef3ced0b chore(deps): bump langchain-core from 1.2.22 to 1.2.28 in /libs/checkpoint-conformance (#7448)
Bumps [langchain-core](https://github.com/langchain-ai/langchain) from
1.2.22 to 1.2.28.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langchain/releases">langchain-core's
releases</a>.</em></p>
<blockquote>
<h2>langchain-core==1.2.28</h2>
<p>Changes since langchain-core==1.2.27</p>
<p>release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</p>
<h2>langchain-core==1.2.27</h2>
<p>Changes since langchain-core==1.2.26</p>
<p>release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)
chore: add comment explaining <code>pygments&gt;=2.20.0</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36570">#36570</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>.</p>
<h2>langchain-core==1.2.26</h2>
<p>Changes since langchain-core==1.2.25</p>
<p>release(core): 1.2.26 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36511">#36511</a>)
fix(core): add init validator and serialization mappings for Bedrock
models (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34510">#34510</a>)
feat(core): add <code>ChatBaseten</code> to serializable mapping (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36510">#36510</a>)
chore(core): drop <code>gpt-3.5-turbo</code> from docstrings (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36497">#36497</a>)
fix(core): correct parameter names in filter_messages docstring example
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36462">#36462</a>)</p>
<h2>langchain-core==1.2.25</h2>
<p>Changes since langchain-core==1.2.24</p>
<p>release(core): 1.2.25 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36473">#36473</a>)
fix(core): harden check for txt files in deprecated prompt loading
functions (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>)
fix(core): fixed typos in the documentation (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36459">#36459</a>)</p>
<p>Credit to Jeff Ponte (<a
href="https://github.com/JDP-Security"><code>@​JDP-Security</code></a>)
for reporting the symlink resolution issue resolved in <a
href="https://redirect.github.com/langchain-ai/langchain/issues/36471">#36471</a>.</p>
<h2>langchain-core==1.2.24</h2>
<p>Changes since langchain-core==1.2.23</p>
<p>release(core): 1.2.24 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36434">#36434</a>)
feat(core): impute placeholder filenames for OpenAI file inputs (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36433">#36433</a>)
chore: pygments&gt;=2.20.0 across all packages (CVE-2026-4539) (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36385">#36385</a>)
fix(core): add &quot;computer&quot; to _WellKnownOpenAITools (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36261">#36261</a>)</p>
<h2>langchain-core==1.2.23</h2>
<p>Changes since langchain-core==1.2.22</p>
<p>release(core): 1.2.23 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36323">#36323</a>)
revert: Revert &quot;fix(core): trace invocation params in
metadata&quot; (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36322">#36322</a>)
chore: bump requests from 2.32.5 to 2.33.0 in /libs/core (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36243">#36243</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/dd7c3eb3a4acfc834b038ec9dbde94478c66776e"><code>dd7c3eb</code></a>
release(core): release 1.2.28 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36614">#36614</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/af2ed47c6f008cdd551f3c0d87db3774c8dfe258"><code>af2ed47</code></a>
fix(core): add more sanitization to templates (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36612">#36612</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7e5858d8078124f98f10102da21414689467c132"><code>7e5858d</code></a>
release(standard-tests): 1.1.6 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36610">#36610</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/fe99cb29123b704a90f5c8587a757def3b1471e0"><code>fe99cb2</code></a>
fix(standard-tests): update standard tests for sandbox backends (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36036">#36036</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/65bbd47cb2721c51ef8638f9e7da35247c4bfdde"><code>65bbd47</code></a>
chore(model-profiles): refresh model profile data (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36596">#36596</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/64864041168606535dfbd39055c0dca3dd61b5ba"><code>6486404</code></a>
release(core): 1.2.27 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36586">#36586</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/7629c747260cbaed7ca55466d5b9e1b520a7de77"><code>7629c74</code></a>
fix(core): handle symlinks in deprecated prompt save path (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36585">#36585</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/ce21bf469d7493f4716bc30feb15a5b3f16ebe1e"><code>ce21bf4</code></a>
ci: convert working-directory to validated dropdown (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36575">#36575</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b8698eacbd2960c7e3195018f42992bf2c9d69c7"><code>b8698ea</code></a>
release(ollama): 1.1.0 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/36574">#36574</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/3beba77e2e23d498fda07f9b8d6ba00aabfaf69f"><code>3beba77</code></a>
feat(ollama): support <code>response_format</code> (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/34612">#34612</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.22...langchain-core==1.2.28">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=langchain-core&package-manager=uv&previous-version=1.2.22&new-version=1.2.28)](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-09 16:46:02 -07:00
1629794658 chore: update conformance lint (#7459)
Co-authored-by: Will Fu-Hinthorn <will@langchain.dev>
2026-04-08 18:22:18 -07:00
94 changed files with 7203 additions and 12606 deletions
-45
View File
@@ -9,9 +9,6 @@ on:
permissions:
contents: read
env:
GO_VERSION: "1.23"
jobs:
build:
runs-on: ubuntu-latest
@@ -54,23 +51,12 @@ jobs:
python-version: ${{ matrix.python-version }}
enable-cache: "false"
working-directory: libs/cli
- name: Set up Go
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install cli globally
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
run: pip install -e .
- name: Build Go CLI binary
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
run: make build-go
- name: Build service ${{ matrix.example.name }}
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
working-directory: ${{ matrix.example.workdir }}
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t ${{ matrix.example.tag }}
- name: Test service ${{ matrix.example.name }}
@@ -78,8 +64,6 @@ jobs:
working-directory: ${{ matrix.example.workdir }}
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
# Prepare environment file from local or parent example directory
if [ -f .env.example ]; then cp .env.example .env; elif [ -f ../.env.example ]; then cp ../.env.example .env && cp ../.env.example ../.env; fi
@@ -92,27 +76,18 @@ jobs:
- name: Build JS service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/js-examples
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-e
- name: Build JS monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/js-monorepo-example
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
- name: Build Python monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/python-monorepo-example
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
- name: Test Python monorepo service
@@ -120,8 +95,6 @@ jobs:
working-directory: libs/cli/python-monorepo-example
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
cp apps/agent/.env.example apps/agent/.env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
@@ -130,9 +103,6 @@ jobs:
- name: Build prerelease reqs service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-h
- name: Test prerelease reqs service
@@ -140,8 +110,6 @@ jobs:
working-directory: libs/cli/examples/graph_prerelease_reqs
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
cp ../.env.example .env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
@@ -166,18 +134,12 @@ jobs:
- name: Build and test prerelease reqs fail service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-i || [ $? -eq 1 ]
- name: Build uv simple service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/uv-examples/simple
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-uv-simple
- name: Test uv simple service
@@ -185,8 +147,6 @@ jobs:
working-directory: libs/cli/uv-examples/simple
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
cp .env.example .env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
@@ -195,9 +155,6 @@ jobs:
- name: Build uv monorepo service
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
working-directory: libs/cli/uv-examples/monorepo/apps/agent
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
langgraph build -t langgraph-test-uv-monorepo
- name: Test uv monorepo service
@@ -205,8 +162,6 @@ jobs:
working-directory: libs/cli/uv-examples/monorepo/apps/agent
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: |
cp .env.example .env
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
-29
View File
@@ -11,9 +11,6 @@ on:
permissions:
contents: read
env:
GO_VERSION: "1.23"
jobs:
build:
runs-on: ubuntu-latest
@@ -29,23 +26,12 @@ jobs:
name: "test #${{ matrix.python-version }}"
steps:
- uses: actions/checkout@v6
- name: Get changed files
id: changed-files
if: ${{ inputs.working-directory == 'libs/cli' && github.event_name != 'workflow_dispatch' }}
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
with:
filter: "libs/cli/**"
- name: Set up Python ${{ matrix.python-version }}
uses: ./.github/actions/uv_setup
with:
python-version: ${{ matrix.python-version }}
cache-suffix: test-${{ inputs.working-directory }}
working-directory: ${{ inputs.working-directory }}
- name: Set up Go
if: ${{ inputs.working-directory == 'libs/cli' && (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') }}
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Login to Docker Hub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
if: ${{ !github.event.pull_request.head.repo.fork }}
@@ -58,26 +44,11 @@ jobs:
working-directory: ${{ inputs.working-directory }}
run: uv sync --frozen --group test --no-dev
- name: Build Go CLI binary
if: ${{ inputs.working-directory == 'libs/cli' && (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: make build-go
- name: Run tests
shell: bash
working-directory: ${{ inputs.working-directory }}
run: make test
- name: Run Go CLI smoke tests
if: ${{ inputs.working-directory == 'libs/cli' && (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
LANGGRAPH_USE_GO_CLI: "1"
LANGGRAPH_GO_CLI_PATH: ${{ github.workspace }}/libs/cli/langgraph_cli/bin/langgraph
run: TEST='tests/unit_tests/test_go_cli_smoke.py' make test
- name: Ensure the tests did not create any additional files
shell: bash
working-directory: ${{ inputs.working-directory }}
-45
View File
@@ -10,7 +10,6 @@ on:
env:
PYTHON_VERSION: "3.10"
GO_VERSION: "1.23"
permissions:
contents: read
@@ -33,17 +32,6 @@ jobs:
cache-suffix: "release"
working-directory: ${{ inputs.working-directory }}
- name: Set up Go
if: inputs.working-directory == 'libs/cli'
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cross-compile Go binaries
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: make build-go-all GO_BIN_DIR=build/go-bin
# We want to keep this build stage *separate* from the release stage,
# so that there's no sharing of permissions between them.
# The release stage has trusted publishing and GitHub repo contents write access,
@@ -55,40 +43,7 @@ jobs:
# > It is strongly advised to separate jobs for building [...]
# > from the publish job.
# https://github.com/pypa/gh-action-pypi-publish#non-goals
- name: Build CLI platform wheels
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: |
TARGETS=(
"linux-amd64 manylinux_2_17_x86_64.manylinux2014_x86_64"
"linux-arm64 manylinux_2_17_aarch64.manylinux2014_aarch64"
"linux-arm manylinux_2_17_armv7l.manylinux2014_armv7l"
"linux-386 manylinux_2_17_i686.manylinux2014_i686"
"linux-ppc64le manylinux_2_17_ppc64le.manylinux2014_ppc64le"
"linux-s390x manylinux_2_17_s390x.manylinux2014_s390x"
"linux-amd64 musllinux_1_2_x86_64"
"linux-arm64 musllinux_1_2_aarch64"
"linux-arm musllinux_1_2_armv7l"
"linux-386 musllinux_1_2_i686"
"darwin-amd64 macosx_11_0_x86_64"
"darwin-arm64 macosx_11_0_arm64"
"windows-amd64 win_amd64"
"windows-arm64 win_arm64"
"windows-386 win32"
)
for entry in "${TARGETS[@]}"; do
key=$(echo "$entry" | awk '{print $1}')
plat=$(echo "$entry" | awk '{print $2}')
ext=""
if [[ "$key" == windows-* ]]; then ext=".exe"; fi
binary="build/go-bin/langgraph-${key}${ext}"
LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel
done
rm -rf langgraph_cli/bin
uv build
- name: Build project for distribution
if: inputs.working-directory != 'libs/cli'
run: uv build
working-directory: ${{ inputs.working-directory }}
-74
View File
@@ -13,7 +13,6 @@ permissions:
env:
PYTHON_VERSION: "3.11"
GO_VERSION: "1.23"
jobs:
build:
@@ -35,71 +34,6 @@ jobs:
cache-suffix: "release"
working-directory: ${{ inputs.working-directory }}
# -- Go cross-compilation (libs/cli only) --
# When releasing the CLI, we cross-compile the Go binary for each
# platform and build platform-specific wheels that bundle it.
- name: Set up Go
if: inputs.working-directory == 'libs/cli'
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cross-compile Go binaries
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: make build-go-all GO_BIN_DIR=build/go-bin
- name: Run Go tests
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: go test -count=1 -race ./...
- name: Build CLI platform wheels
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
run: |
# Each entry: GO_BINARY_KEY WHEEL_PLATFORM_TAG
#
# Since Go builds are statically linked (CGO_ENABLED=0), the same
# linux binary works on both glibc and musl. We produce separate
# manylinux and musllinux wheels so pip installs the right one.
TARGETS=(
# Linux glibc
"linux-amd64 manylinux_2_17_x86_64.manylinux2014_x86_64"
"linux-arm64 manylinux_2_17_aarch64.manylinux2014_aarch64"
"linux-arm manylinux_2_17_armv7l.manylinux2014_armv7l"
"linux-386 manylinux_2_17_i686.manylinux2014_i686"
"linux-ppc64le manylinux_2_17_ppc64le.manylinux2014_ppc64le"
"linux-s390x manylinux_2_17_s390x.manylinux2014_s390x"
# Linux musl (same Go binaries, different wheel tag)
"linux-amd64 musllinux_1_2_x86_64"
"linux-arm64 musllinux_1_2_aarch64"
"linux-arm musllinux_1_2_armv7l"
"linux-386 musllinux_1_2_i686"
# macOS
"darwin-amd64 macosx_11_0_x86_64"
"darwin-arm64 macosx_11_0_arm64"
# Windows
"windows-amd64 win_amd64"
"windows-arm64 win_arm64"
"windows-386 win32"
)
for entry in "${TARGETS[@]}"; do
key=$(echo "$entry" | awk '{print $1}')
plat=$(echo "$entry" | awk '{print $2}')
ext=""
if [[ "$key" == windows-* ]]; then ext=".exe"; fi
binary="build/go-bin/langgraph-${key}${ext}"
echo "Building wheel for ${key} -> ${plat}..."
LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel
done
# Also build the sdist and pure-Python fallback wheel
rm -rf langgraph_cli/bin
uv build
echo "All wheels built:"
ls -lh dist/
# -- Standard build (all other packages) --
# We want to keep this build stage *separate* from the release stage,
# so that there's no sharing of permissions between them.
# The release stage has trusted publishing and GitHub repo contents write access,
@@ -112,7 +46,6 @@ jobs:
# > from the publish job.
# https://github.com/pypa/gh-action-pypi-publish#non-goals
- name: Build project for distribution
if: inputs.working-directory != 'libs/cli'
run: uv build
working-directory: ${{ inputs.working-directory }}
@@ -308,13 +241,6 @@ jobs:
run: make test
working-directory: ${{ inputs.working-directory }}
- name: Run Go CLI smoke tests
if: inputs.working-directory == 'libs/cli'
working-directory: ${{ inputs.working-directory }}
env:
LANGGRAPH_USE_GO_CLI: "1"
run: uv run pytest tests/unit_tests/test_go_cli_smoke.py
publish:
needs:
- build
+1
View File
@@ -100,3 +100,4 @@ dmypy.json
.turbo
.editorconfig
.scratch
.worktrees/
@@ -339,7 +339,6 @@ async def test_list_metadata_custom_keys(
assert results[0].metadata["run_id"] == "run-abc"
ALL_LIST_TESTS = [
test_list_all,
test_list_by_thread,
+10 -10
View File
@@ -231,7 +231,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.22"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -243,9 +243,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
@@ -263,7 +263,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint-conformance"
version = "0.0.1"
version = "0.0.2"
source = { editable = "." }
dependencies = [
{ name = "langgraph-checkpoint" },
@@ -306,7 +306,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.3"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -319,9 +319,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/bc/8172fefad4f2da888a6d564a27d1fb7d4dbf3c640899c2b40c46235cbe98/langsmith-0.7.3.tar.gz", hash = "sha256:0223b97021af62d2cf53c8a378a27bd22e90a7327e45b353e0069ae60d5d6f9e", size = 988575, upload-time = "2026-02-13T23:25:32.916Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/9d/5a68b6b5e313ffabbb9725d18a71edb48177fd6d3ad329c07801d2a8e862/langsmith-0.7.3-py3-none-any.whl", hash = "sha256:03659bf9274e6efcead361c9c31a7849ea565ae0d6c0d73e1d8b239029eff3be", size = 325718, upload-time = "2026-02-13T23:25:31.52Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -623,7 +623,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -634,9 +634,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
+5
View File
@@ -6,6 +6,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
## Security
> [!IMPORTANT]
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
## Usage
> [!IMPORTANT]
@@ -4,15 +4,17 @@ import threading
from collections import defaultdict
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from typing import Any
from typing import Any, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
_ChannelWritesHistory,
get_checkpoint_id,
get_serializable_checkpoint_metadata,
)
@@ -23,7 +25,11 @@ from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import _internal
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.base import (
SELECT_DELTA_COMBINED_SQL,
BasePostgresSaver,
_DeltaCombinedRow,
)
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
Conn = _internal.Conn # For backward compatibility
@@ -430,6 +436,48 @@ class PostgresSaver(BasePostgresSaver):
with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
def _get_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
single roundtrip; the ancestor walk runs in Python.
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if checkpoint_id is None:
# Caller didn't specify a target — resolve to the latest
# checkpoint on the thread. `get_tuple` without `checkpoint_id`
# returns the newest; its config carries the resolved id.
target = self.get_tuple(config)
if target is None:
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
checkpoint_id = target.config["configurable"]["checkpoint_id"]
with self._cursor() as cur:
cur.execute(
SELECT_DELTA_COMBINED_SQL,
(
channel,
thread_id,
checkpoint_ns,
thread_id,
checkpoint_ns,
channel,
thread_id,
checkpoint_ns,
channel,
),
)
rows = cur.fetchall()
return self._build_delta_channel_writes_history(
channel=channel,
target_id=checkpoint_id,
rows=cast("list[_DeltaCombinedRow]", rows),
)
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
"""
Convert a database row into a CheckpointTuple object.
@@ -4,15 +4,17 @@ import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager
from typing import Any
from typing import Any, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
_ChannelWritesHistory,
get_checkpoint_id,
get_serializable_checkpoint_metadata,
)
@@ -23,7 +25,11 @@ from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.base import (
SELECT_DELTA_COMBINED_SQL,
BasePostgresSaver,
_DeltaCombinedRow,
)
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
Conn = _ainternal.Conn # For backward compatibility
@@ -391,6 +397,46 @@ class AsyncPostgresSaver(BasePostgresSaver):
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
async def _aget_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
single roundtrip; rows are assembled by the shared pure helper on
`BasePostgresSaver`.
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = get_checkpoint_id(config)
if checkpoint_id is None:
target = await self.aget_tuple(config)
if target is None:
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
checkpoint_id = target.config["configurable"]["checkpoint_id"]
async with self._cursor() as cur:
await cur.execute(
SELECT_DELTA_COMBINED_SQL,
(
channel,
thread_id,
checkpoint_ns,
thread_id,
checkpoint_ns,
channel,
thread_id,
checkpoint_ns,
channel,
),
)
rows = await cur.fetchall()
return self._build_delta_channel_writes_history(
channel=channel,
target_id=checkpoint_id,
rows=cast("list[_DeltaCombinedRow]", rows),
)
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
"""
Convert a database row into a CheckpointTuple object.
@@ -4,13 +4,16 @@ import random
import warnings
from collections.abc import Sequence
from importlib.metadata import version as get_version
from typing import Any, cast
from typing import Any, TypedDict, cast
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
BaseCheckpointSaver,
ChannelVersions,
PendingWrite,
_ChannelWritesHistory,
get_checkpoint_id,
)
from langgraph.checkpoint.serde.types import TASKS
@@ -153,6 +156,62 @@ INSERT_CHECKPOINT_WRITES_SQL = """
"""
class _DeltaCombinedRow(TypedDict, total=False):
"""One row from `SELECT_DELTA_COMBINED_SQL` (a UNION ALL of three tables).
Every row carries `_kind` ("p" / "w" / "b") plus whichever columns are
relevant for that kind; irrelevant columns are NULL and typed as `None`.
"""
_kind: str # always present: "p", "w", or "b"
# checkpoint row ("p")
checkpoint_id: str | None
parent_checkpoint_id: str | None
ver: str | None
# write / blob rows ("w", "b")
type: str | None
blob: bytes | None
# write row only ("w")
task_id: str | None
idx: int | None
# blob row only ("b")
version: str | None
# DeltaChannel reconstruction: one UNION ALL query fetches checkpoints,
# writes, and blobs for `channel` in one roundtrip; the ancestor walk runs
# in Python in `_build_delta_channel_writes_history`.
#
# Parameter order: (channel, thread_id, checkpoint_ns,
# thread_id, checkpoint_ns, channel,
# thread_id, checkpoint_ns, channel)
SELECT_DELTA_COMBINED_SQL = """
SELECT 'p'::text AS _kind,
checkpoint_id,
parent_checkpoint_id,
checkpoint -> 'channel_versions' ->> %s AS ver,
NULL::text AS type,
NULL::bytea AS blob,
NULL::text AS task_id,
NULL::int AS idx,
NULL::text AS version
FROM checkpoints
WHERE thread_id = %s AND checkpoint_ns = %s
UNION ALL
SELECT 'w',
checkpoint_id, NULL, NULL,
type, blob, task_id, idx, NULL
FROM checkpoint_writes
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
UNION ALL
SELECT 'b',
NULL, NULL, NULL,
type, blob, NULL, NULL, version
FROM checkpoint_blobs
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
"""
class BasePostgresSaver(BaseCheckpointSaver[str]):
SELECT_SQL = SELECT_SQL
SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
@@ -195,6 +254,83 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
if t.decode() != "empty"
}
def _build_delta_channel_writes_history(
self,
*,
channel: str,
target_id: str,
rows: Sequence[_DeltaCombinedRow],
) -> _ChannelWritesHistory:
"""Reconstruct one delta channel's history from the combined UNION ALL rows.
Pure data transform shared by sync (`PostgresSaver`) and async
(`AsyncPostgresSaver`); both paths run `SELECT_DELTA_COMBINED_SQL`
and feed the tagged rows here.
Walk is newest → oldest from the target's parent. A non-sentinel
blob in `checkpoint_blobs` (a pre-delta snapshot) terminates the
walk and is returned as the seed so replay starts from it.
Writes stored at `target_id` itself are pending writes for the next
step and are excluded — the walk begins at the target's parent.
"""
parent_of: dict[str, str | None] = {}
ver_of: dict[str, str | None] = {}
writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
blob_by_ver: dict[str, tuple[str, bytes]] = {}
for r in rows:
kind = r["_kind"]
if kind == "p":
cid = cast(str, r["checkpoint_id"])
parent_of[cid] = r["parent_checkpoint_id"]
ver_of[cid] = r["ver"]
elif kind == "w":
cid = cast(str, r["checkpoint_id"])
writes_by_cid.setdefault(cid, []).append(
cast(
"tuple[str, bytes, str, int]",
(r["type"], r["blob"], r["task_id"], r["idx"]),
)
)
else: # kind == "b"
blob_by_ver[cast(str, r["version"])] = cast(
"tuple[str, bytes]", (r["type"], r["blob"])
)
# newest write first per ancestor (task_id DESC, idx DESC)
for ws in writes_by_cid.values():
ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
ancestors: list[str] = []
cur_cid: str | None = parent_of.get(target_id)
while cur_cid is not None:
ancestors.append(cur_cid)
cur_cid = parent_of.get(cur_cid)
if not ancestors:
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
collected: list[PendingWrite] = [] # newest first; reversed at the end
for cid in ancestors:
# Collect writes first — they encode the transition FROM this
# ancestor's state to its child's and must be included even if
# this ancestor is also the seed checkpoint.
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
val = self.serde.loads_typed((type_tag, write_blob))
collected.append((task_id, channel, val))
# Then check seed terminator.
ver = ver_of.get(cid)
if ver is not None:
seed_blob = blob_by_ver.get(ver)
if seed_blob is not None and seed_blob[0] != "empty":
blob_value = self.serde.loads_typed(seed_blob)
if blob_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=blob_value, writes=collected)
collected.reverse() # oldest → newest
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
def _dump_blobs(
self,
thread_id: str,
+1 -1
View File
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=2.1.2,<5.0.0",
"langgraph-checkpoint>=4.0.3,<5.0.0",
"orjson>=3.11.5",
"psycopg>=3.2.0",
"psycopg-pool>=3.2.0",
+46 -2
View File
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
load_checkpoint_tuple = saver._load_checkpoint_tuple
def patched_load_checkpoint_tuple(value):
async def patched_load_checkpoint_tuple(value):
value["checkpoint"].pop("channel_values", None)
return load_checkpoint_tuple(value)
return await load_checkpoint_tuple(value)
monkeypatch.setattr(
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
@@ -371,3 +371,47 @@ async def test_get_checkpoint_no_channel_values(
checkpoint = await saver.aget_tuple(config)
assert checkpoint.checkpoint["channel_values"] == {}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
"""AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
pytest.importorskip(
"langgraph.channels.delta", reason="langgraph core not installed"
)
from typing import Annotated
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from typing_extensions import TypedDict
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
n = len(state["messages"])
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
async with _saver(saver_name) as saver:
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "diff-channel-test-1"}}
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
await graph.ainvoke(
{"messages": [HumanMessage(content="there", id="h2")]}, config
)
state = await graph.aget_state(config)
msgs = state.values["messages"]
assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
assert msgs[0].content == "hi"
assert msgs[1].content == "reply-1"
assert msgs[2].content == "there"
assert msgs[3].content == "reply-3"
+129 -10
View File
@@ -240,7 +240,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.22"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -252,14 +252,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.0.2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -382,7 +382,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.6.4"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -392,11 +392,12 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -950,7 +951,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -961,9 +962,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -1284,6 +1285,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
+5
View File
@@ -2,6 +2,11 @@
Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
## Security
> [!IMPORTANT]
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
## Usage
```python
+129 -10
View File
@@ -249,7 +249,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.22"
version = "1.2.28"
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/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.0.2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -385,7 +385,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.6.4"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -395,11 +395,12 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -862,7 +863,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -873,9 +874,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -1211,6 +1212,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
+3
View File
@@ -26,6 +26,9 @@ You must pass these when invoking the graph as part of the configurable part of
`langgraph_checkpoint` also defines protocol for serialization/deserialization (serde) and provides an default implementation (`langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer`) that handles a wide variety of types, including LangChain and LangGraph primitives, datetimes, enums and more.
> [!IMPORTANT]
> **Checkpoint deserialization security:** By default the serializer allows any Python type found in checkpoint data. New applications should set the environment variable `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list to `JsonPlusSerializer` to restrict deserialization to known-safe types.
### Pending writes
When a graph node fails mid-execution at a given superstep, LangGraph stores pending checkpoint writes from any other nodes that completed successfully at that superstep, so that whenever we resume graph execution from that superstep we don't re-run the successful nodes.
@@ -3,7 +3,7 @@ from __future__ import annotations
import copy
import logging
from collections.abc import AsyncIterator, Collection, Iterator, Mapping, Sequence
from typing import ( # noqa: UP035
from typing import (
Any,
Generic,
Literal,
@@ -18,6 +18,9 @@ from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.serde.types import (
DELTA_SENTINEL as DELTA_SENTINEL,
)
from langgraph.checkpoint.serde.types import (
ERROR,
INTERRUPT,
@@ -28,6 +31,8 @@ from langgraph.checkpoint.serde.types import (
V = TypeVar("V", int, float, str)
PendingWrite = tuple[str, str, Any]
logger = logging.getLogger(__name__)
@@ -119,6 +124,30 @@ class CheckpointTuple(NamedTuple):
pending_writes: list[PendingWrite] | None = None
class _ChannelWritesHistory(NamedTuple):
"""Result of `BaseCheckpointSaver._get_channel_writes_history`.
Storage-level view of what one channel wrote across the ancestor chain
of a target checkpoint:
* `seed` — the nearest ancestor's stored blob value for this channel,
or `DELTA_SENTINEL` if the walk reached the root without finding a
stored value. A non-sentinel seed typically indicates a pre-delta
snapshot preserved across a channel-type migration (e.g.
`BinaryOperatorAggregate` storage extended under `DeltaChannel`).
* `writes` — on-path deltas oldest→newest, one `PendingWrite` per
step that wrote to this channel. Writes stored at the target
checkpoint itself are pending for the next super-step and are
excluded.
Experimental: method surface may change; the NamedTuple shape is the
contract.
"""
seed: Any
writes: list[PendingWrite]
class BaseCheckpointSaver(Generic[V]):
"""Base class for creating a graph checkpointer.
@@ -457,6 +486,104 @@ class BaseCheckpointSaver(Generic[V]):
"""
raise NotImplementedError
def _get_tuple_raw(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Pure storage read used by `_get_channel_writes_history`.
Must return the same value as `get_tuple` but must NOT trigger channel
reconstruction; otherwise the channel-hydration path would re-enter
`_get_channel_writes_history`. Override only if `get_tuple` itself
performs channel hydration.
"""
return self.get_tuple(config)
async def _aget_tuple_raw(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Async version of `_get_tuple_raw`. See docstring there."""
return await self.aget_tuple(config)
def _get_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""**Experimental.** Query one channel's writes along the parent chain.
Storage-level query, not channel semantics: returns `(seed, writes)`
reflecting what storage knows about a single channel across the
ancestor chain of the target checkpoint identified by `config`.
* `writes` — on-path deltas oldest→newest as `PendingWrite` tuples.
Writes stored at the target `checkpoint_id` itself are pending
for the next super-step and are excluded.
* `seed` — the nearest ancestor's stored blob value for this
channel; `DELTA_SENTINEL` if the walk reached the root without
finding a stored value. A non-sentinel seed typically indicates
a pre-delta snapshot preserved across a channel-type migration.
Walks the **parent chain** (not `list(before=...)`): for forked
threads, only on-path ancestors contribute.
Reference implementation walks `get_tuple` + `parent_config`,
inspecting each ancestor's `channel_values[channel]` for the seed
terminator. Savers with direct storage access (`InMemorySaver`,
`PostgresSaver`) override for performance; the return contract is
fixed here.
Underscore-prefixed because the method surface is experimental.
"""
collected: list[PendingWrite] = [] # newest first; reversed at the end
target_tuple = self._get_tuple_raw(config)
cursor_config: RunnableConfig | None = (
target_tuple.parent_config if target_tuple else None
)
while cursor_config is not None:
tup = self._get_tuple_raw(cursor_config)
if tup is None:
break
# Collect this ancestor's writes FIRST — they encode the
# transition from this ancestor's state to its child's, so
# they must be included whether or not this ancestor is the
# seed terminator.
if tup.pending_writes:
# Within a superstep, pending_writes are oldest→newest;
# reverse to scan newest-first.
for write in reversed(tup.pending_writes):
if write[1] != channel:
continue
collected.append(write)
# Seed terminator: any non-sentinel blob on an ancestor
# establishes the reconstruction base. Stop here.
ancestor_value = tup.checkpoint["channel_values"].get(channel)
if ancestor_value is not None and ancestor_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=ancestor_value, writes=collected)
cursor_config = tup.parent_config
collected.reverse()
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
async def _aget_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
"""Async version of `_get_channel_writes_history`. See docstring there."""
collected: list[PendingWrite] = []
target_tuple = await self._aget_tuple_raw(config)
cursor_config: RunnableConfig | None = (
target_tuple.parent_config if target_tuple else None
)
while cursor_config is not None:
tup = await self._aget_tuple_raw(cursor_config)
if tup is None:
break
if tup.pending_writes:
for write in reversed(tup.pending_writes):
if write[1] != channel:
continue
collected.append(write)
ancestor_value = tup.checkpoint["channel_values"].get(channel)
if ancestor_value is not None and ancestor_value is not DELTA_SENTINEL:
collected.reverse()
return _ChannelWritesHistory(seed=ancestor_value, writes=collected)
cursor_config = tup.parent_config
collected.reverse()
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
@@ -14,16 +14,20 @@ from typing import Any
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
WRITES_IDX_MAP,
BaseCheckpointSaver,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
PendingWrite,
SerializerProtocol,
_ChannelWritesHistory,
get_checkpoint_id,
get_checkpoint_metadata,
)
from langgraph.checkpoint.serde.types import _DeltaSnapshot
logger = logging.getLogger(__name__)
@@ -121,16 +125,114 @@ class InMemorySaver(
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
def _load_blobs(
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
self,
thread_id: str,
checkpoint_ns: str,
versions: ChannelVersions,
) -> dict[str, Any]:
channel_values: dict[str, Any] = {}
for k, v in versions.items():
kk = (thread_id, checkpoint_ns, k, v)
if kk in self.blobs:
vv = self.blobs[kk]
if vv[0] != "empty":
channel_values[k] = self.serde.loads_typed(vv)
return channel_values
result: dict[str, Any] = {}
for k, ver in versions.items():
kk = (thread_id, checkpoint_ns, k, ver)
if kk not in self.blobs:
continue
vv = self.blobs[kk]
if vv[0] == "empty":
continue
result[k] = self.serde.loads_typed(vv)
return result
def _get_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
checkpoint_id = config["configurable"].get("checkpoint_id", "")
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
# Walk the parent chain newest→oldest. Skip the target itself —
# writes stored AT `checkpoint_id` are pending for the next step
# (pregel applies them via `apply_writes`; they aren't part of the
# snapshot value AT `checkpoint_id`).
chain: list[str] = []
target_entry = ns_storage.get(checkpoint_id)
current: str | None = target_entry[2] if target_entry is not None else None
while current is not None:
entry = ns_storage.get(current)
if entry is None:
break
chain.append(current)
_, _, parent = entry
current = parent
# Scan newest→oldest. A pre-delta blob on an ancestor terminates the
# walk and is bound as `seed`; without this, a thread migrated from
# pre-delta storage would replay ancestor writes all the way to the
# root AND miss any value that lived only in the old blob (e.g. from
# `update_state`).
#
# At each ancestor, check the blob BEFORE processing its pending
# writes: a pre-delta blob represents the state AT that ancestor,
# which already subsumes any writes stored under it. Processing
# those writes first would fold them into the reconstructed value
# twice (once via the blob, once via replay).
collected: list[PendingWrite] = [] # newest first
for cp_id in chain: # newest → oldest
entry = ns_storage.get(cp_id)
if entry is not None:
ckpt = self.serde.loads_typed(entry[0])
ver = ckpt.get("channel_versions", {}).get(channel)
if ver is not None:
blob_entry = self.blobs.get(
(thread_id, checkpoint_ns, channel, ver)
)
if blob_entry is not None and blob_entry[0] != "empty":
blob_value = self.serde.loads_typed(blob_entry)
if blob_value is not DELTA_SENTINEL:
if isinstance(blob_value, _DeltaSnapshot):
# Step-based snapshot: the blob is state AT this
# ancestor, but the ancestor's pending_writes
# encode the NEXT step's transition and are NOT
# subsumed by the snapshot — collect them first.
step_writes = self.writes.get(
(thread_id, checkpoint_ns, cp_id), {}
)
for (_task_id, _idx), (
tid,
ch,
serialized,
_,
) in sorted(step_writes.items(), reverse=True):
if ch != channel:
continue
collected.append(
(tid, ch, self.serde.loads_typed(serialized))
)
collected.reverse()
return _ChannelWritesHistory(
seed=blob_value, writes=collected
)
# Pre-delta blob: state AT this ancestor already
# subsumes its pending_writes — skip them.
collected.reverse()
return _ChannelWritesHistory(
seed=blob_value, writes=collected
)
step_writes = self.writes.get((thread_id, checkpoint_ns, cp_id), {})
# Within a superstep, sorted by (task_id, idx) = oldest → newest;
# reverse for newest-first scan.
for (_task_id, _idx), (tid, ch, serialized, _) in sorted(
step_writes.items(), reverse=True
):
if ch != channel:
continue
val = self.serde.loads_typed(serialized)
collected.append((tid, ch, val))
collected.reverse()
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
async def _aget_channel_writes_history(
self, config: RunnableConfig, channel: str
) -> _ChannelWritesHistory:
return self._get_channel_writes_history(config, channel)
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the in-memory storage.
@@ -1,3 +1,10 @@
"""Msgpack deserialization safety controls.
Set ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict checkpoint deserialization
to the types listed in ``SAFE_MSGPACK_TYPES``. Without this, any Python
callable stored in checkpoint data will be imported and executed on load.
"""
import os
from collections.abc import Iterable
from typing import cast
@@ -33,19 +33,40 @@ from langchain_core.load.load import Reviver
from langgraph.checkpoint.serde import _msgpack as _lg_msgpack
from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.event_hooks import emit_serde_event
from langgraph.checkpoint.serde.types import SendProtocol
from langgraph.checkpoint.serde.types import (
DELTA_SENTINEL,
SendProtocol,
_DeltaSentinel,
_DeltaSnapshot,
)
from langgraph.store.base import Item
if TYPE_CHECKING:
from langgraph.checkpoint.serde._msgpack import (
AllowedMsgpackModules,
)
from langgraph.checkpoint.serde.types import SendProtocol
LC_REVIVER = Reviver()
EMPTY_BYTES = b""
logger = logging.getLogger(__name__)
# Dedup log warnings across process lifetime; cap bounds state if types are
# dynamically generated (also acts as a circuit breaker on warning volume).
# Dedup is best-effort: racing threads may each emit once for the same key,
# and warnings are silently dropped once _MAX_WARNED_TYPES is reached.
_MAX_WARNED_TYPES = 1000
_warned_unregistered_types: set[tuple[str, str]] = set()
_warned_blocked_types: set[tuple[str, str]] = set()
def _warn_once(
seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object
) -> None:
if key in seen or len(seen) >= _MAX_WARNED_TYPES:
return
seen.add(key)
logger.warning(msg, *args)
class JsonPlusSerializer(SerializerProtocol):
"""Serializer that uses ormsgpack, with optional fallbacks.
@@ -56,6 +77,10 @@ class JsonPlusSerializer(SerializerProtocol):
class and called within the Pregel loop. It should not be used on untrusted
python objects. If an attacker can write directly to your checkpoint database,
they may be able to trigger code execution when data is deserialized.
Set the environment variable ``LANGGRAPH_STRICT_MSGPACK=true`` to restrict
deserialization to a built-in allowlist of safe types. You can also pass
an explicit ``allowed_msgpack_modules`` to the constructor.
"""
def __init__(
@@ -70,8 +95,11 @@ class JsonPlusSerializer(SerializerProtocol):
) -> None:
if allowed_msgpack_modules is _lg_msgpack._SENTINEL:
if _lg_msgpack.STRICT_MSGPACK_ENABLED:
# Strict: only SAFE_MSGPACK_TYPES are allowed.
allowed_msgpack_modules = None
else:
# Permissive (default): all types allowed with a warning.
# Set LANGGRAPH_STRICT_MSGPACK=true to lock this down.
allowed_msgpack_modules = True
self.pickle_fallback = pickle_fallback
self._allowed_json_modules: set[tuple[str, ...]] | Literal[True] | None = (
@@ -269,10 +297,16 @@ EXT_METHOD_SINGLE_ARG = 3
EXT_PYDANTIC_V1 = 4
EXT_PYDANTIC_V2 = 5
EXT_NUMPY_ARRAY = 6
EXT_DELTA_SNAPSHOT = 7
EXT_DELTA_SENTINEL = 8
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
if hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
if isinstance(obj, _DeltaSnapshot):
return ormsgpack.Ext(EXT_DELTA_SNAPSHOT, _msgpack_enc(obj.value))
elif isinstance(obj, _DeltaSentinel):
return ormsgpack.Ext(EXT_DELTA_SENTINEL, b"")
elif hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
return ormsgpack.Ext(
EXT_PYDANTIC_V2,
_msgpack_enc(
@@ -527,10 +561,13 @@ def _create_msgpack_ext_hook(
"name": name,
}
)
logger.warning(
_warn_once(
_warned_unregistered_types,
key,
"Deserializing unregistered type %s.%s from checkpoint. "
"This will be blocked in a future version. "
"Add to allowed_msgpack_modules to silence: [(%r, %r)]",
"Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add "
"to allowed_msgpack_modules to allow explicitly: [(%r, %r)]",
module,
name,
module,
@@ -548,7 +585,9 @@ def _create_msgpack_ext_hook(
"name": name,
}
)
logger.warning(
_warn_once(
_warned_blocked_types,
key,
"Blocked deserialization of %s.%s - not in allowed_msgpack_modules. "
"Add to allowed_msgpack_modules to allow: [(%r, %r)]",
module,
@@ -581,7 +620,15 @@ def _create_msgpack_ext_hook(
return False
def ext_hook(code: int, data: bytes) -> Any:
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
if code == EXT_DELTA_SENTINEL:
return DELTA_SENTINEL
elif code == EXT_DELTA_SNAPSHOT:
return _DeltaSnapshot(
ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
)
elif code == EXT_CONSTRUCTOR_SINGLE_ARG:
try:
tup = ormsgpack.unpackb(
data, ext_hook=ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
@@ -1,6 +1,7 @@
from collections.abc import Sequence
from typing import (
Any,
NamedTuple,
Protocol,
TypeVar,
runtime_checkable,
@@ -14,6 +15,39 @@ INTERRUPT = "__interrupt__"
RESUME = "__resume__"
TASKS = "__pregel_tasks"
class _DeltaSentinel:
"""Singleton marker stored (as zero bytes) in checkpoint_blobs for a
DeltaChannel field. The actual per-step writes live in checkpoint_writes
and are replayed through the reducer at load time.
Compare with `is DELTA_SENTINEL` — `loads_typed` always returns the same
module-level instance.
"""
__slots__ = ()
def __repr__(self) -> str:
return "DELTA_SENTINEL"
DELTA_SENTINEL = _DeltaSentinel()
class _DeltaSnapshot(NamedTuple):
"""Snapshot blob for a DeltaChannel with finite snapshot_frequency.
Stored in checkpoint_blobs via the `EXT_DELTA_SNAPSHOT` msgpack ext code.
The ancestor walk in `_get_channel_writes_history` terminates when it
encounters this type (any non-sentinel blob stops the walk).
`from_checkpoint` reconstructs the channel value directly from `.value`
without replaying writes — the snapshot IS the accumulated state.
"""
value: Any
Value = TypeVar("Value", covariant=True)
Update = TypeVar("Update", contravariant=True)
C = TypeVar("C")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.1.0a2"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
+9
View File
@@ -29,6 +29,8 @@ from langgraph.checkpoint.serde.jsonplus import (
EXT_METHOD_SINGLE_ARG,
JsonPlusSerializer,
_msgpack_enc,
_warned_blocked_types,
_warned_unregistered_types,
)
@@ -102,6 +104,13 @@ def test_msgpack_method_pathlib_blocked_encrypted_strict(
class TestEncryptedSerializerMsgpackAllowlist:
"""Test msgpack allowlist behavior through EncryptedSerializer."""
@pytest.fixture(autouse=True)
def _reset_warned_types(self) -> None:
# Warning dedup state is process-global; reset per-test so each case
# sees a fresh slate and assertions about warning emission are stable.
_warned_unregistered_types.clear()
_warned_blocked_types.clear()
def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test safe types deserialize without warnings through encryption."""
serde = _make_encrypted_serde()
+28 -2
View File
@@ -35,6 +35,8 @@ from langgraph.checkpoint.serde.jsonplus import (
JsonPlusSerializer,
_msgpack_enc,
_msgpack_ext_hook_to_json,
_warned_blocked_types,
_warned_unregistered_types,
)
from langgraph.store.base import Item
@@ -580,6 +582,14 @@ def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None
assert result is not None
@pytest.fixture(autouse=True)
def _reset_warned_types() -> None:
# Warning dedup state is process-global; reset per-test so each case sees
# a fresh slate and assertions about warning emission are stable.
_warned_unregistered_types.clear()
_warned_blocked_types.clear()
def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None:
"""Pydantic models not in allowlist should log warning but still deserialize."""
current = _lg_msgpack.STRICT_MSGPACK_ENABLED
@@ -595,6 +605,12 @@ def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) ->
assert "unregistered type" in caplog.text.lower()
assert "allowed_msgpack_modules" in caplog.text
assert result == obj
# Second deserialization of the same type should NOT produce another warning
caplog.clear()
result2 = serde.loads_typed(dumped)
assert "unregistered type" not in caplog.text.lower()
assert result2 == obj
_lg_msgpack.STRICT_MSGPACK_ENABLED = current
@@ -639,7 +655,6 @@ def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) ->
def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None:
"""allowed_msgpack_modules=None should block unregistered types."""
serde = JsonPlusSerializer(allowed_msgpack_modules=None)
obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world"))
@@ -657,7 +672,6 @@ def test_msgpack_allowlist_blocks_non_listed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Allowlists should block unregistered types even if msgpack is enabled."""
serde = JsonPlusSerializer(
allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")]
)
@@ -983,3 +997,15 @@ def test_msgpack_nested_pydantic_serializes_as_dict(
# No blocking should occur - inner is serialized as dict, not ext
assert "blocked" not in caplog.text.lower()
assert result == obj
def test_delta_sentinel_serde_round_trip() -> None:
from langgraph.checkpoint.base import DELTA_SENTINEL
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
serde = JsonPlusSerializer()
type_tag, blob = serde.dumps_typed(DELTA_SENTINEL)
assert type_tag == "msgpack"
assert blob # non-empty ext envelope
loaded = serde.loads_typed((type_tag, blob))
assert loaded is DELTA_SENTINEL
+358 -3
View File
@@ -6,19 +6,32 @@ from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
Checkpoint,
CheckpointMetadata,
create_checkpoint,
empty_checkpoint,
)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.serde.jsonplus import (
JsonPlusSerializer,
_warned_blocked_types,
_warned_unregistered_types,
)
class MemoryPydantic(BaseModel):
foo: str
@pytest.fixture(autouse=True)
def _reset_warned_types() -> None:
# Warning dedup state is process-global; reset per-test so each case sees
# a fresh slate and assertions about warning emission are stable.
_warned_unregistered_types.clear()
_warned_blocked_types.clear()
class TestMemorySaver:
@pytest.fixture(autouse=True)
def setup(self) -> None:
@@ -196,8 +209,6 @@ class TestMemorySaver:
async def test_memory_saver() -> None:
from langgraph.checkpoint.memory import InMemorySaver
memory_saver = InMemorySaver()
assert isinstance(memory_saver, InMemorySaver)
@@ -308,3 +319,347 @@ def test_memory_saver_with_allowlist_proxy_isolated() -> None:
assert direct is not None
expected = obj.model_dump() if hasattr(obj, "model_dump") else obj.dict()
assert direct.checkpoint["channel_values"]["foo"] == expected
class TestInMemorySaverDeltaChannel:
def test_load_blobs_returns_sentinel_for_delta_channel(self) -> None:
"""_load_blobs returns DELTA_SENTINEL for delta channels (reconstruction deferred)."""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
v1 = "00000000000000000000000000000001.0000000000000000"
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(DELTA_SENTINEL)
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp1["channel_versions"][channel] = v1
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
}
result = saver._load_blobs(thread_id, ns, {channel: v1})
assert channel in result
assert result[channel] is DELTA_SENTINEL
def test_get_channel_writes_collects_ancestor_writes_only(self) -> None:
"""_get_channel_writes_history collects ancestor writes oldest→newest,
and excludes writes stored at the target checkpoint itself (those are
pending writes for the next step, applied separately by pregel)."""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp2 = empty_checkpoint()
cp2["id"] = "cp2"
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
}
# Writes stored at cp1 produced the cp1 snapshot; part of history.
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
"task1",
channel,
serde.dumps_typed({"content": "hi"}),
"",
)
# Writes stored at cp2 are pending — they will produce cp3 when the
# step that loaded cp2 completes. They MUST NOT appear in the
# reconstructed snapshot value at cp2.
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
"task2",
channel,
serde.dumps_typed({"content": "pending"}),
"",
)
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": "cp2",
}
}
result = saver._get_channel_writes_history(config, channel)
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == [{"content": "hi"}]
def test_get_channel_writes_at_root_returns_empty(self) -> None:
"""Reconstructing the root checkpoint's state: no ancestors → []."""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
}
saver.writes[(thread_id, ns, "cp1")][("task1", 0)] = (
"task1",
channel,
serde.dumps_typed({"content": "pending"}),
"",
)
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": "cp1",
}
}
result = saver._get_channel_writes_history(config, channel)
assert result.seed is DELTA_SENTINEL
assert result.writes == []
class TestBaseFallbackGetChannelWrites:
"""Exercises the `BaseCheckpointSaver._get_channel_writes_history` default
implementation the path third-party savers inherit when they don't
override `_get_channel_writes_history` themselves.
Regression guard for a bug where the fallback passed the caller's config
(with `checkpoint_id`) straight to `self.list()`, which most savers
collapse to a single row causing the fallback to return `[]`.
"""
def _build_saver_with_chain(self) -> tuple[InMemorySaver, str, str]:
"""Build an InMemorySaver with a 3-checkpoint chain and per-step writes
for a `messages` channel.
Returns `(saver, thread_id, namespace)`. The saver subclass deletes the
InMemorySaver override so the base class fallback is exercised.
"""
class _ThirdPartyStyleSaver(InMemorySaver):
_get_channel_writes_history = (
InMemorySaver.__mro__[1]._get_channel_writes_history # type: ignore[attr-defined]
)
_aget_channel_writes_history = (
InMemorySaver.__mro__[1]._aget_channel_writes_history # type: ignore[attr-defined]
)
saver = _ThirdPartyStyleSaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
cp0 = empty_checkpoint()
cp0["id"] = "00000000000000000000000000000001.0000000000000000"
cp1 = empty_checkpoint()
cp1["id"] = "00000000000000000000000000000002.0000000000000000"
cp2 = empty_checkpoint()
cp2["id"] = "00000000000000000000000000000003.0000000000000000"
saver.storage[thread_id][ns] = {
cp0["id"]: (serde.dumps_typed(cp0), serde.dumps_typed({}), None),
cp1["id"]: (serde.dumps_typed(cp1), serde.dumps_typed({}), cp0["id"]),
cp2["id"]: (serde.dumps_typed(cp2), serde.dumps_typed({}), cp1["id"]),
}
# Writes under cp0 produced cp1's state; writes under cp1 produced cp2's.
saver.writes[(thread_id, ns, cp0["id"])][("task1", 0)] = (
"task1",
channel,
serde.dumps_typed({"content": "first"}),
"",
)
saver.writes[(thread_id, ns, cp1["id"])][("task2", 0)] = (
"task2",
channel,
serde.dumps_typed({"content": "second"}),
"",
)
return saver, thread_id, ns
def test_fallback_returns_ancestor_writes_oldest_first(self) -> None:
saver, thread_id, ns = self._build_saver_with_chain()
target_id = "00000000000000000000000000000003.0000000000000000"
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target_id,
}
}
result = saver._get_channel_writes_history(config, "messages")
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == [{"content": "first"}, {"content": "second"}]
async def test_async_fallback_returns_ancestor_writes_oldest_first(self) -> None:
saver, thread_id, ns = self._build_saver_with_chain()
target_id = "00000000000000000000000000000003.0000000000000000"
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target_id,
}
}
result = await saver._aget_channel_writes_history(config, "messages")
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == [{"content": "first"}, {"content": "second"}]
async def test_async_fallback_concurrent_tasks_do_not_interfere(self) -> None:
"""Regression: the re-entrancy guard must be task-local, not thread-local.
Two concurrent `_aget_channel_writes_history` calls on the same
event-loop thread must each see their full reconstructed writes. A
`threading.local()` guard would let whichever task set it first
short-circuit the other to `writes=[]`.
"""
import asyncio
saver, thread_id, ns = self._build_saver_with_chain()
# Force the two tasks to interleave across the `set(True)` boundary:
# each `aget_tuple` yields control, so if the guard were thread-local
# the second task would observe `active=True` set by the first.
orig_aget_tuple = saver.aget_tuple
async def slow_aget_tuple(config: RunnableConfig) -> Any:
await asyncio.sleep(0)
return await orig_aget_tuple(config)
saver.aget_tuple = slow_aget_tuple # type: ignore[method-assign]
target_id = "00000000000000000000000000000003.0000000000000000"
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target_id,
}
}
results = await asyncio.gather(
saver._aget_channel_writes_history(config, "messages"),
saver._aget_channel_writes_history(config, "messages"),
)
expected_values = [{"content": "first"}, {"content": "second"}]
for result in results:
assert result.seed is DELTA_SENTINEL
values = [v for _, _, v in result.writes]
assert values == expected_values
class TestPreDeltaBlobTerminator:
"""Verify the pre-delta blob terminator: when the ancestor walk hits a
checkpoint whose blob for the channel is a real value (not
DELTA_SENTINEL), reconstruction seeds from it and stops. This guards
* back-compat: a thread written by pre-delta code, then extended under
delta reconstruction must return the correct value without walking
past the last pre-delta ancestor;
* perf: without the terminator, every reconstruct-after-migration would
walk all the way to the thread root.
"""
def _build_mixed_thread(self) -> tuple[InMemorySaver, str, str, str, str]:
"""Three-checkpoint chain: cp1 (pre-delta, blob=[A]), cp2 (delta,
write=B), cp3 (delta, write=C). Reconstructing at cp3 must yield
seed=[A] + writes=[B, C].
Returns `(saver, thread_id, ns, channel, cp3_id)`.
"""
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
v1 = "00000000000000000000000000000001.0"
v2 = "00000000000000000000000000000002.0"
v3 = "00000000000000000000000000000003.0"
# Pre-delta: cp1 stored a real blob for the channel.
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(["A"])
# Delta-era: cp2 and cp3 store sentinels; real writes in checkpoint_writes.
saver.blobs[(thread_id, ns, channel, v2)] = serde.dumps_typed(DELTA_SENTINEL)
saver.blobs[(thread_id, ns, channel, v3)] = serde.dumps_typed(DELTA_SENTINEL)
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp1["channel_versions"][channel] = v1
cp2 = empty_checkpoint()
cp2["id"] = "cp2"
cp2["channel_versions"][channel] = v2
cp3 = empty_checkpoint()
cp3["id"] = "cp3"
cp3["channel_versions"][channel] = v3
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
"cp3": (serde.dumps_typed(cp3), serde.dumps_typed({}), "cp2"),
}
# Write under cp1 would be from the pre-delta era and MUST be ignored
# (the blob already captures it). We add one and assert it is not
# folded into the reconstructed result.
saver.writes[(thread_id, ns, "cp1")][("task0", 0)] = (
"task0",
channel,
serde.dumps_typed("PRE-DELTA-WRITE"),
"",
)
saver.writes[(thread_id, ns, "cp2")][("task2", 0)] = (
"task2",
channel,
serde.dumps_typed("B"),
"",
)
saver.writes[(thread_id, ns, "cp3")][("task3", 0)] = (
"task3",
channel,
serde.dumps_typed("PENDING-AT-TARGET"),
"",
)
return saver, thread_id, ns, channel, "cp3"
def test_seed_from_pre_delta_ancestor_blob(self) -> None:
saver, thread_id, ns, channel, target = self._build_mixed_thread()
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target,
}
}
result = saver._get_channel_writes_history(config, channel)
# Seed came from the pre-delta blob at cp1.
assert result.seed == ["A"]
# Delta-era writes from cp2 replay through the reducer on top of seed.
# cp3 is the target — its own write is pending for the NEXT step and
# must be excluded.
values = [v for _, _, v in result.writes]
assert values == ["B"]
def test_pre_delta_blob_terminates_walk_before_older_writes(self) -> None:
"""Writes stored at the pre-delta ancestor itself must not be replayed
(the blob subsumes them)."""
saver, thread_id, ns, channel, target = self._build_mixed_thread()
config: RunnableConfig = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": ns,
"checkpoint_id": target,
}
}
result = saver._get_channel_writes_history(config, channel)
values = [v for _, _, v in result.writes]
# The pre-delta write under cp1 must not appear (the blob subsumes it).
assert "PRE-DELTA-WRITE" not in values
# And the pending write at the target is never folded in.
assert "PENDING-AT-TARGET" not in values
+132 -10
View File
@@ -7,6 +7,9 @@ resolution-markers = [
"python_full_version < '3.11'",
]
[options]
prerelease-mode = "allow"
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -267,7 +270,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.23"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -279,14 +282,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/47/a5f21b651e9cbd7a26c3e5809336d10a0be94ef7bdf6bea47f2ad9fff1a8/langchain_core-1.2.23.tar.gz", hash = "sha256:fdec64f90cfea25317e88d9803c44684af1f4e30dec4e58320dd7393bb0f0785", size = 841684, upload-time = "2026-03-27T23:28:14.6Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/5a/6ff2d76618e4cac531ea51d4ef44c6add36575a84c3f0f8877aee68c951a/langchain_core-1.2.23-py3-none-any.whl", hash = "sha256:70866dfc5275b7840ce272ff70f0ff216c8666ab25dc1b41964a4ef58c02a3ff", size = 506709, upload-time = "2026-03-27T23:28:13.372Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.1.0a2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -369,7 +372,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.6.4"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -379,11 +382,12 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1117,7 +1121,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1128,9 +1132,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -1515,6 +1519,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/ee/f9f1d656ad168681bb0f6b092372c1e533c4416b8069b1896a175c46e484/xxhash-3.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87ff03d7e35c61435976554477a7f4cd1704c3596a89a8300d5ce7fc83874a71", size = 32845, upload-time = "2025-10-02T14:33:51.573Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/93508d9460b292c74a09b83d16750c52a0ead89c51eea9951cb97a60d959/xxhash-3.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f572dfd3d0e2eb1a57511831cf6341242f5a9f8298a45862d085f5b93394a27d", size = 30807, upload-time = "2025-10-02T14:33:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/28c93a3662f2d200c70704efe74aab9640e824f8ce330d8d3943bf7c9b3c/xxhash-3.6.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89952ea539566b9fed2bbd94e589672794b4286f342254fad28b149f9615fef8", size = 193786, upload-time = "2025-10-02T14:33:54.272Z" },
{ url = "https://files.pythonhosted.org/packages/c1/96/fec0be9bb4b8f5d9c57d76380a366f31a1781fb802f76fc7cda6c84893c7/xxhash-3.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e6f2ffb07a50b52465a1032c3cf1f4a5683f944acaca8a134a2f23674c2058", size = 212830, upload-time = "2025-10-02T14:33:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a0/c706845ba77b9611f81fd2e93fad9859346b026e8445e76f8c6fd057cc6d/xxhash-3.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5b848ad6c16d308c3ac7ad4ba6bede80ed5df2ba8ed382f8932df63158dd4b2", size = 211606, upload-time = "2025-10-02T14:33:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/164126a2999e5045f04a69257eea946c0dc3e86541b400d4385d646b53d7/xxhash-3.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a034590a727b44dd8ac5914236a7b8504144447a9682586c3327e935f33ec8cc", size = 444872, upload-time = "2025-10-02T14:33:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4b/55ab404c56cd70a2cf5ecfe484838865d0fea5627365c6c8ca156bd09c8f/xxhash-3.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a8f1972e75ebdd161d7896743122834fe87378160c20e97f8b09166213bf8cc", size = 193217, upload-time = "2025-10-02T14:33:59.724Z" },
{ url = "https://files.pythonhosted.org/packages/45/e6/52abf06bac316db33aa269091ae7311bd53cfc6f4b120ae77bac1b348091/xxhash-3.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee34327b187f002a596d7b167ebc59a1b729e963ce645964bbc050d2f1b73d07", size = 210139, upload-time = "2025-10-02T14:34:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/34/37/db94d490b8691236d356bc249c08819cbcef9273a1a30acf1254ff9ce157/xxhash-3.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:339f518c3c7a850dd033ab416ea25a692759dc7478a71131fe8869010d2b75e4", size = 197669, upload-time = "2025-10-02T14:34:03.664Z" },
{ url = "https://files.pythonhosted.org/packages/b7/36/c4f219ef4a17a4f7a64ed3569bc2b5a9c8311abdb22249ac96093625b1a4/xxhash-3.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf48889c9630542d4709192578aebbd836177c9f7a4a2778a7d6340107c65f06", size = 210018, upload-time = "2025-10-02T14:34:05.325Z" },
{ url = "https://files.pythonhosted.org/packages/fd/06/bfac889a374fc2fc439a69223d1750eed2e18a7db8514737ab630534fa08/xxhash-3.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5576b002a56207f640636056b4160a378fe36a58db73ae5c27a7ec8db35f71d4", size = 413058, upload-time = "2025-10-02T14:34:06.925Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d1/555d8447e0dd32ad0930a249a522bb2e289f0d08b6b16204cfa42c1f5a0c/xxhash-3.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af1f3278bd02814d6dedc5dec397993b549d6f16c19379721e5a1d31e132c49b", size = 190628, upload-time = "2025-10-02T14:34:08.669Z" },
{ url = "https://files.pythonhosted.org/packages/d1/15/8751330b5186cedc4ed4b597989882ea05e0408b53fa47bcb46a6125bfc6/xxhash-3.6.0-cp310-cp310-win32.whl", hash = "sha256:aed058764db109dc9052720da65fafe84873b05eb8b07e5e653597951af57c3b", size = 30577, upload-time = "2025-10-02T14:34:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/53f87e8b5871a6eb2ff7e89c48c66093bda2be52315a8161ddc54ea550c4/xxhash-3.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:e82da5670f2d0d98950317f82a0e4a0197150ff19a6df2ba40399c2a3b9ae5fb", size = 31487, upload-time = "2025-10-02T14:34:11.618Z" },
{ url = "https://files.pythonhosted.org/packages/9f/00/60f9ea3bb697667a14314d7269956f58bf56bb73864f8f8d52a3c2535e9a/xxhash-3.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a082ffff8c6ac07707fb6b671caf7c6e020c75226c561830b73d862060f281d", size = 27863, upload-time = "2025-10-02T14:34:12.619Z" },
{ url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
{ url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
{ url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
{ url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
{ url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
{ url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
{ url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
{ url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
{ url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
{ url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
{ url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
{ url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
{ url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
{ url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
{ url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
{ url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
{ url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
{ url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
{ url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
{ url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
{ url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
{ url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
{ url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
{ url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
{ url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
{ url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
{ url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
{ url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
{ url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
{ url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
{ url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
-2
View File
@@ -1,3 +1 @@
.langgraph_api/
# Go cross-compiled binaries (built at release time, bundled into wheels)
langgraph_cli/bin/
-374
View File
@@ -1,374 +0,0 @@
# Go Migration Plan
## Goal
Move the full `langgraph` CLI implementation to Go while preserving existing
Python distribution and invocation flows.
Users should continue to be able to run:
- `langgraph ...`
- `uv run langgraph ...`
- `uvx langgraph ...`
During phase 1, the Go path is gated behind a feature flag. The Python package
remains the public entrypoint and launcher.
## Non-Goals
This phase does not include:
- JS migration
- `langsmith-cli` integration
- user-facing command renames
- intentional CLI behavior changes
- a long-lived dual implementation
## Source Of Truth
There will be one implementation of CLI behavior:
- shared Go implementation lives in the `langgraph` repo
- standalone Go `langgraph` binary uses that implementation
- Python `langgraph-cli` package is a thin launcher around that binary
- legacy Python implementation exists only temporarily as fallback during rollout
## Phase 1 Artifacts
Phase 1 ships these artifacts:
- shared Go package(s) in `langgraph`
- standalone `langgraph` Go binary
- Python wheel `langgraph-cli` that bundles the platform-specific Go binary
- Python launcher entrypoint that can route to legacy Python or Go
JS is explicitly out of scope for phase 1.
## User-Facing Command Scope
Phase 1 scope is the whole `langgraph` CLI, not just deploy.
Target command coverage:
- `langgraph deploy ...`
- `langgraph build ...`
- `langgraph up ...`
- `langgraph dockerfile ...`
- `langgraph dev ...`
- `langgraph new ...`
The goal is full parity with the current Python CLI command surface.
## Compatibility Contract
Behavior must not regress.
Required parity:
- exact JSON output where commands emit JSON
- exact or equivalent error semantics
- same exit codes
- same argument and flag behavior
- same generated artifacts for:
- Dockerfile output
- docker compose / inline compose output
- same API request semantics where mocked in tests
Human-readable output should be same or better, but not worse.
## Repo Ownership
Shared implementation lives in the current `langgraph` repo.
Reasons:
- current CLI spec and tests already live here
- rollout is initially only for `langgraph-cli`
- command compatibility should be driven by existing behavior in this repo
## Architecture
Use process boundaries, not language FFI.
Python should not call a Go shared library directly. Instead:
- Python launcher locates bundled `langgraph` Go binary
- Python launcher `exec`s or subprocesses into the Go binary
- Go handles all command execution
- for `dev`, Go subprocesses back into Python
This keeps the boundary simple and cross-platform.
## Go Package Structure
Recommended structure:
- `pkg/cli/config`
- parse and validate `langgraph.json`
- normalize config model
- `pkg/cli/docker`
- docker capability detection
- compose generation
- Dockerfile/build plan generation
- `pkg/cli/deploy`
- deployment flows
- host backend client
- polling, logs, revision logic
- `pkg/cli/dev`
- `dev` command orchestration
- Python subprocess handoff
- `pkg/cli/cmds`
- command runner functions with typed options/results
- no Cobra-specific code here
- `cmd/langgraph`
- standalone Go binary wrapping shared packages
Business logic should live in shared packages, not directly in CLI adapter code.
## Python Wrapper Model
The Python package remains installed as `langgraph-cli`, with entrypoint
`langgraph`.
During migration, the wrapper decides whether to route to legacy Python or Go.
Wrapper behavior:
1. inspect feature flags
2. resolve Go binary path
3. if Go path is active, `exec` into Go binary
4. otherwise fall back to legacy Python implementation
Long-term target:
- remove fallback
- Python wrapper always launches bundled Go binary
## Feature Flags
Temporary rollout env vars:
- `LANGGRAPH_USE_GO_CLI=1`
- route the Python wrapper to the Go binary instead of legacy Python
- `LANGGRAPH_GO_CLI_PATH=/path/to/langgraph`
- internal/dev/CI override for binary path resolution
- not intended as a long-term public interface
- `LANGGRAPH_CALLING_PYTHON=/path/to/python`
- set by the Python wrapper before invoking Go
- used by Go for `dev`
`LANGGRAPH_GO_CLI_PATH` is mainly for local development and CI and can be
removed later.
## `dev` Invocation Contract
`dev` is the main tricky area.
Design rule:
- Go owns CLI parsing and routing
- Python owns the actual in-process local dev server runtime
Flow for `uv run langgraph dev`:
1. `uv` selects the Python interpreter/environment
2. Python wrapper starts
3. Python wrapper sets `LANGGRAPH_CALLING_PYTHON=sys.executable`
4. Python wrapper launches Go binary
5. Go receives `dev`
6. Go shells out to that exact Python interpreter for the actual Python runtime behavior
This preserves the current selected Python environment.
Go Python resolution order for `dev`:
1. `LANGGRAPH_CALLING_PYTHON`
2. optional explicit override if added later
3. environment-derived interpreter / active venv
4. fallback detection
5. clear failure
The critical constraint is: if the user entered through Python, `dev` should
use that exact Python when possible.
## Why Not FFI
Do not use:
- cgo shared libs
- Python-Go FFI bindings
- embedded Python in Go
- RPC unless absolutely necessary
Reasons:
- packaging complexity
- cross-platform pain
- no advantage for a CLI architecture
- much worse release/debug story
Process-level boundaries are the right choice here.
## Packaging Constraints
The Go binary should be bundled inside Python wheels.
Preferred distribution model:
- build platform-specific `langgraph-cli` wheels
- each wheel includes the matching `langgraph` Go binary
- Python launcher resolves and executes the bundled binary
Do not rely on runtime download of the binary for normal operation.
Support matrix target:
- all OS/arch targets that are currently expected to be supported
- at minimum, align with the practical support matrix desired for the CLI,
using `orjson` support as a rough proxy if needed
If a platform is unsupported, fail clearly rather than silently falling back
forever.
## Release Constraints
Phase 1 versioning applies to:
- shared Go implementation
- standalone Go `langgraph` binary
- PyPI `langgraph-cli` wrapper
They should stay on one version line.
Constraint:
- bundled Go binary version must exactly match the Python wrapper version for
the migrated surface
The wrapper should detect obvious mismatch and fail clearly if it occurs.
## Migration Strategy
Use a big-bang hidden implementation change with gradual activation.
Phase 1 rollout:
1. implement full Go path behind `LANGGRAPH_USE_GO_CLI`
2. keep default behavior on legacy Python
3. run dual CI for legacy and Go-backed paths
4. dogfood with feature flag
5. flip default to Go
6. keep fallback briefly
7. remove fallback in about two weeks
This is a big internal rewrite with gradual external activation.
## CI Strategy
Dual CI is required during migration.
Run both variants:
- legacy Python implementation
- Python wrapper -> Go binary implementation
Required parity checks:
- help output
- exit code
- stdout
- stderr
- generated Dockerfile output
- generated compose output
- mocked deployment API request semantics
- validation errors / usage errors
Goal is not merely "both tests pass". Goal is "both implementations behave
identically enough to swap by default safely".
## Parity Test Philosophy
Use the current Python CLI tests as the behavioral spec.
Priority test areas:
- config validation
- compose/Dockerfile generation
- deployment flows
- error and prompt behavior
- command help / command surface
Where practical, add golden comparisons so regressions are obvious.
## Implementation Order Inside Phase 1
Even though rollout is one hidden phase, implementation should proceed in this
order:
1. wrapper contract and env contract
2. Go command scaffolding and package boundaries
3. config + docker/build/compose logic
4. deploy flows
5. remaining commands
6. `dev` subprocess orchestration
7. parity hardening in CI
This reduces risk because `dev` is the highest-uncertainty area.
## Command Ownership Constraint
All command behavior should live in Go once ported.
Do not allow:
- some flags parsed in Python and others in Go
- duplicated command logic across Python and Go
- separate behavior definitions for legacy and migrated commands
The wrapper should be thin only.
## Fallback Constraint
Fallback is temporary, not a product feature.
Policy:
- use feature flag during migration
- flip default after parity confidence
- remove legacy Python implementation roughly two weeks later
Do not normalize to permanent dual execution paths.
## Documentation Constraint
During migration, documentation should stay conservative:
- existing Python install flow remains primary
- feature flag is acceptable for internal/dogfood docs
- avoid broad external messaging about the Go implementation until default is flipped
## Open Issues To Track
These are not blockers, but they need explicit implementation decisions:
- exact bundled wheel layout for binaries
- exact list of supported OS/arch targets
- whether to expose a public `--python` override for `dev`
- whether some pretty output is allowed to improve while keeping parsed output stable
## Summary
Phase 1 plan:
- move the entire `langgraph` CLI implementation into shared Go code in the
`langgraph` repo
- ship a standalone `langgraph` Go binary
- keep `langgraph-cli` on PyPI as a thin launcher that bundles and executes
that binary
- preserve `uv run` / `uvx` behavior
- handle `dev` by passing the calling Python path through the wrapper and
having Go subprocess back into Python
- gate everything behind `LANGGRAPH_USE_GO_CLI`
- run dual CI until parity is proven
- flip default
- remove legacy fallback quickly
+3 -84
View File
@@ -1,21 +1,15 @@
.PHONY: test lint type format test-integration update-schema bump-version
.PHONY: test-go lint-go format-go
.PHONY: build-go build-go-all clean-go-bin
######################
# TESTING AND COVERAGE
######################
TEST?= "tests/unit_tests"
GO_FILES=$(shell find cmd internal -type f -name '*.go' 2>/dev/null)
test: test-go
test:
uv run pytest $(TEST)
test-integration:
uv run pytest tests/integration_tests
test-go:
[ ! -f go.mod ] || go test ./...
######################
# LINTING AND FORMATTING
######################
@@ -29,94 +23,19 @@ lint_package: PYTHON_FILES=langgraph_cli
lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests: lint-go
lint lint_diff lint_package lint_tests:
uv run ruff check .
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
lint-go:
[ -z "$(GO_FILES)" ] || test -z "$$(gofmt -l $(GO_FILES))"
type:
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff: format-go
format format_diff:
uv run ruff format $(PYTHON_FILES)
uv run ruff check --select I --fix $(PYTHON_FILES)
format-go:
[ -z "$(GO_FILES)" ] || gofmt -w $(GO_FILES)
######################
# GO BINARY CROSS-COMPILATION
######################
GO_MODULE=github.com/langchain-ai/langgraph/libs/cli
GO_BINARY=cmd/langgraph/main.go
GO_VERSION_PKG=$(GO_MODULE)/internal/version
GO_BIN_DIR=langgraph_cli/bin
GO_VERSION=$(shell grep -m 1 '^__version__' langgraph_cli/__init__.py | cut -d '"' -f 2)
GO_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GO_DATE=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GO_LDFLAGS=-s -w \
-X '$(GO_VERSION_PKG).Version=$(GO_VERSION)' \
-X '$(GO_VERSION_PKG).Commit=$(GO_COMMIT)' \
-X '$(GO_VERSION_PKG).Date=$(GO_DATE)'
# Platform matrix — covers every platform orjson ships for. Since the Go
# binary is statically linked (CGO_ENABLED=0), the same linux binary works
# on both glibc and musl. Musllinux wheels reuse the linux binary but are
# tagged differently so pip installs them on Alpine/musl systems.
GO_PLATFORMS = \
linux/amd64 \
linux/arm64 \
linux/arm \
linux/386 \
linux/ppc64le \
linux/s390x \
darwin/amd64 \
darwin/arm64 \
windows/amd64 \
windows/arm64 \
windows/386
# Build for the current platform (development).
build-go:
@mkdir -p $(GO_BIN_DIR)
go build -ldflags "$(GO_LDFLAGS)" -o $(GO_BIN_DIR)/langgraph $(GO_BINARY)
@echo "Built $(GO_BIN_DIR)/langgraph"
# Build for a single target: make build-go-target GOOS=linux GOARCH=amd64
build-go-target:
$(eval EXT=$(if $(filter windows,$(GOOS)),.exe,))
@mkdir -p $(GO_BIN_DIR)
GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \
go build -ldflags "$(GO_LDFLAGS)" \
-o $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT) $(GO_BINARY)
@echo "Built $(GO_BIN_DIR)/langgraph-$(GOOS)-$(GOARCH)$(EXT)"
# Build for all platforms.
build-go-all:
@mkdir -p $(GO_BIN_DIR)
@for platform in $(GO_PLATFORMS); do \
os=$${platform%/*}; arch=$${platform#*/}; \
ext=""; \
if [ "$$os" = "windows" ]; then ext=".exe"; fi; \
echo "Building $$os/$$arch..."; \
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 \
go build -ldflags "$(GO_LDFLAGS)" \
-o $(GO_BIN_DIR)/langgraph-$$os-$$arch$$ext $(GO_BINARY) || exit 1; \
done
@echo "All platforms built in $(GO_BIN_DIR)/"
clean-go-bin:
rm -rf $(GO_BIN_DIR)
######################
# SCHEMA AND VERSIONING
######################
update-schema:
uv run python generate_schema.py
-11
View File
@@ -1,11 +0,0 @@
package main
import (
"os"
"github.com/langchain-ai/langgraph/libs/cli/internal/root"
)
func main() {
os.Exit(root.Run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -5,5 +5,5 @@ description = "Test for prerelease stuff"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-openai==1.0.1"
"langchain-openai==1.1.14"
]
@@ -5,7 +5,7 @@ description = "Test for prerelease stuff"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-openai==1.0.0a2",
"langchain-openai==1.1.14",
"langchain-anthropic==1.0.0a5",
"langgraph==1.1.5"
]
@@ -5,7 +5,7 @@ description = "Test for prerelease stuff"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"langchain-openai==1.0.0a2",
"langchain-openai==1.1.14",
"langgraph==1.1.2",
"langchain_community>=0.3.0",
]
-3
View File
@@ -1,3 +0,0 @@
module github.com/langchain-ai/langgraph/libs/cli
go 1.23.0
-59
View File
@@ -1,59 +0,0 @@
"""Hatch build hook that bundles the platform-specific Go binary into the wheel.
Usage:
1. Cross-compile: make build-go-target GOOS=linux GOARCH=amd64
2. Set LANGGRAPH_GO_BINARY to the built binary path
3. Build wheel: uv build --wheel
The hook copies the binary into langgraph_cli/bin/ so the entrypoint can find it.
If LANGGRAPH_GO_BINARY is not set, the wheel is built without a binary (pure Python
fallback fine for development and the legacy code path).
"""
from __future__ import annotations
import os
import shutil
import stat
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class GoBinaryBuildHook(BuildHookInterface):
PLUGIN_NAME = "go-binary"
def initialize(self, version: str, build_data: dict) -> None:
bin_dir = Path("langgraph_cli/bin")
if bin_dir.exists():
shutil.rmtree(bin_dir)
binary_path = os.environ.get("LANGGRAPH_GO_BINARY")
if not binary_path:
return
source = Path(binary_path)
if not source.is_file():
msg = f"LANGGRAPH_GO_BINARY points to missing file: {source}"
raise FileNotFoundError(msg)
bin_dir.mkdir(parents=True, exist_ok=True)
# Determine output name (langgraph or langgraph.exe)
dest_name = "langgraph.exe" if source.suffix == ".exe" else "langgraph"
dest = bin_dir / dest_name
shutil.copy2(str(source), str(dest))
# Ensure executable permission
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# Tell hatch to include the binary in the wheel
build_data["shared_data"] = {}
build_data["force_include"] = {
str(dest): f"langgraph_cli/bin/{dest_name}",
}
# Set the platform tag so pip installs the right wheel
platform_tag = os.environ.get("LANGGRAPH_WHEEL_PLAT")
if platform_tag:
build_data["tag"] = f"py3-none-{platform_tag}"
-698
View File
@@ -1,698 +0,0 @@
// Package config provides validation for langgraph.json configuration files.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
MinNodeVersion = "20"
DefaultNodeVersion = "20"
MinPythonVersion = "3.11"
DefaultPythonVersion = "3.11"
DefaultImageDistro = "debian"
)
var validDistros = []string{"debian", "wolfi", "bookworm"}
var knownConfigKeys = map[string]bool{
"python_version": true,
"node_version": true,
"api_version": true,
"base_image": true,
"image_distro": true,
"pip_config_file": true,
"pip_installer": true,
"source": true,
"dependencies": true,
"dockerfile_lines": true,
"graphs": true,
"env": true,
"store": true,
"auth": true,
"encryption": true,
"http": true,
"webhooks": true,
"checkpointer": true,
"ui": true,
"ui_config": true,
"keep_pkg_tools": true,
"_INTERNAL_docker_tag": true,
"project_root": true,
"package": true,
}
var nodeExtensions = map[string]bool{
".ts": true, ".mts": true, ".cts": true,
".js": true, ".mjs": true, ".cjs": true,
}
// isNodeGraph checks whether a graph spec refers to a Node.js file.
func isNodeGraph(spec any) bool {
var filePath string
switch v := spec.(type) {
case string:
filePath = strings.SplitN(v, ":", 2)[0]
case map[string]any:
if p, _ := v["path"].(string); p != "" {
filePath = strings.SplitN(p, ":", 2)[0]
}
}
return nodeExtensions[filepath.Ext(filePath)]
}
// getSourceKind extracts source.kind from a raw config.
func getSourceKind(raw map[string]any) string {
source, ok := raw["source"]
if !ok {
return ""
}
m, ok := source.(map[string]any)
if !ok {
return ""
}
kind, _ := m["kind"].(string)
return kind
}
// getString returns the string value for key, or "" if missing/wrong type.
func getString(raw map[string]any, key string) string {
v, _ := raw[key].(string)
return v
}
// parseVersion parses "3.11" or "0.8.1" into integer parts.
func parseVersion(s string) ([]int, error) {
s = strings.SplitN(s, "-", 2)[0]
parts := strings.Split(s, ".")
result := make([]int, len(parts))
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil, fmt.Errorf("invalid version part: %s", p)
}
result[i] = n
}
return result, nil
}
// versionLessThan returns true if a < b (component-wise).
func versionLessThan(a, b []int) bool {
for i := 0; i < len(a) && i < len(b); i++ {
if a[i] < b[i] {
return true
}
if a[i] > b[i] {
return false
}
}
return len(a) < len(b)
}
// ValidateConfig validates a raw config map and returns a normalised copy.
// Errors match the Python CLI's click.UsageError messages exactly.
func ValidateConfig(raw map[string]any) (map[string]any, error) {
// --- detect graph types ---
graphs, _ := raw["graphs"].(map[string]any)
hasNode, hasPython := false, false
for _, spec := range graphs {
if isNodeGraph(spec) {
hasNode = true
} else {
hasPython = true
}
}
// --- version defaults ---
nodeVersion := getString(raw, "node_version")
pythonVersion := getString(raw, "python_version")
if hasNode && nodeVersion == "" {
nodeVersion = DefaultNodeVersion
}
if hasPython && pythonVersion == "" {
pythonVersion = DefaultPythonVersion
}
imageDistro := getString(raw, "image_distro")
if imageDistro == "" {
imageDistro = DefaultImageDistro
}
// --- mutual exclusion: _INTERNAL_docker_tag vs api_version ---
_, hasInternalTag := raw["_INTERNAL_docker_tag"]
_, hasAPIVersion := raw["api_version"]
if hasInternalTag && hasAPIVersion {
return nil, fmt.Errorf("Cannot specify both _INTERNAL_docker_tag and api_version.")
}
// --- api_version format ---
if apiVersion := getString(raw, "api_version"); apiVersion != "" {
base := strings.SplitN(apiVersion, "-", 2)[0]
parts := strings.Split(base, ".")
if len(parts) > 3 {
return nil, fmt.Errorf("Version must be major or major.minor or major.minor.patch.")
}
for _, p := range parts {
if _, err := strconv.Atoi(p); err != nil {
return nil, fmt.Errorf(
"Invalid version format: %s.\n\n"+
"Pin to a minor version, e.g.:\n"+
" \"api_version\": \"0.8\"", apiVersion)
}
}
}
// --- build result config with defaults ---
config := map[string]any{
"node_version": nodeVersion,
"python_version": pythonVersion,
"pip_config_file": raw["pip_config_file"],
"pip_installer": "auto",
"source": raw["source"],
"base_image": raw["base_image"],
"image_distro": imageDistro,
"dependencies": raw["dependencies"],
"dockerfile_lines": raw["dockerfile_lines"],
"graphs": raw["graphs"],
"env": raw["env"],
"store": raw["store"],
"auth": raw["auth"],
"encryption": raw["encryption"],
"http": raw["http"],
"webhooks": raw["webhooks"],
"checkpointer": raw["checkpointer"],
"ui": raw["ui"],
"ui_config": raw["ui_config"],
"keep_pkg_tools": raw["keep_pkg_tools"],
}
if raw["pip_installer"] != nil {
config["pip_installer"] = raw["pip_installer"]
}
if hasInternalTag {
config["_INTERNAL_docker_tag"] = raw["_INTERNAL_docker_tag"]
}
if hasAPIVersion {
config["api_version"] = raw["api_version"]
}
// Apply list defaults.
if config["dependencies"] == nil {
config["dependencies"] = []any{}
}
if config["dockerfile_lines"] == nil {
config["dockerfile_lines"] = []any{}
}
if config["graphs"] == nil {
config["graphs"] = map[string]any{}
}
if config["env"] == nil {
config["env"] = map[string]any{}
}
// --- node_version validation ---
if nodeVersion != "" {
if strings.Contains(nodeVersion, ".") {
return nil, fmt.Errorf("Node.js version must be major version only")
}
major, err := strconv.Atoi(nodeVersion)
if err != nil {
return nil, fmt.Errorf(
"Invalid Node.js version format: %s. Use major version only (e.g., '20').",
nodeVersion)
}
minMajor, _ := strconv.Atoi(MinNodeVersion)
if major < minMajor {
return nil, fmt.Errorf(
"Node.js version %s is not supported. "+
"Minimum required version is %s.\n\n"+
"Set node_version to %s or higher:\n"+
" \"node_version\": \"%s\"",
nodeVersion, MinNodeVersion, MinNodeVersion, MinNodeVersion)
}
}
// --- pip_installer validation ---
if pi, ok := raw["pip_installer"].(string); ok {
switch pi {
case "auto", "pip", "uv":
// valid
default:
return nil, fmt.Errorf(
"Invalid pip_installer: '%s'. "+
"Consider using uv-based source management instead:\n\n"+
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}",
pi)
}
}
// --- source validation ---
sourceKind := getSourceKind(raw)
if source := raw["source"]; source != nil {
if _, ok := source.(map[string]any); !ok {
return nil, fmt.Errorf(
"`source` must be an object, e.g.:\n" +
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
}
if sourceKind != "uv" {
return nil, fmt.Errorf(
"Invalid source.kind. The only supported value is 'uv':\n" +
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
}
}
// --- python_version validation ---
if pythonVersion != "" {
base := strings.SplitN(pythonVersion, "-", 2)[0]
dotParts := strings.Split(base, ".")
allDigits := true
for _, p := range dotParts {
if _, err := strconv.Atoi(p); err != nil {
allDigits = false
break
}
}
if len(dotParts) != 2 || !allDigits {
fix := MinPythonVersion
if len(dotParts) >= 2 {
fix = dotParts[0] + "." + dotParts[1]
}
return nil, fmt.Errorf(
"Invalid Python version format: %s. "+
"Use 'major.minor' format — patch version cannot be specified.\n\n"+
" \"python_version\": \"%s\"",
pythonVersion, fix)
}
pyParsed, _ := parseVersion(pythonVersion)
minParsed, _ := parseVersion(MinPythonVersion)
if versionLessThan(pyParsed, minParsed) {
return nil, fmt.Errorf(
"Python version %s is not supported. "+
"Minimum required version is %s.\n\n"+
" \"python_version\": \"%s\"",
pythonVersion, MinPythonVersion, MinPythonVersion)
}
if strings.Contains(pythonVersion, "bullseye") {
return nil, fmt.Errorf(
"Bullseye images were deprecated in version 0.4.13. " +
"Please use 'bookworm' or 'debian' instead.")
}
// dependencies required when not uv
deps, _ := config["dependencies"].([]any)
if sourceKind != "uv" && len(deps) == 0 {
return nil, fmt.Errorf(
"No dependencies found in config. " +
"Consider using uv-based source management:\n\n" +
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
}
}
// --- graphs required ---
graphMap, _ := config["graphs"].(map[string]any)
if len(graphMap) == 0 {
return nil, fmt.Errorf(
"No graphs found in config. Add at least one graph, e.g.:\n" +
" \"graphs\": {\n" +
" \"agent\": \"./my_agent/graph.py:graph\"\n" +
" }")
}
// --- image_distro validation ---
if imageDistro == "bullseye" {
return nil, fmt.Errorf(
"Bullseye images were deprecated in version 0.4.13. " +
"Please use 'bookworm' or 'debian' instead.")
}
validDistro := false
for _, d := range validDistros {
if imageDistro == d {
validDistro = true
break
}
}
if !validDistro {
quoted := make([]string, len(validDistros))
for i, d := range validDistros {
quoted[i] = fmt.Sprintf("'%s'", d)
}
return nil, fmt.Errorf(
"Invalid image_distro: '%s'. "+
"Must be one of: %s.\n\n"+
" \"image_distro\": \"wolfi\" (recommended)",
imageDistro, strings.Join(quoted, ", "))
}
// --- uv source mode validation ---
if sourceKind == "uv" {
var errs []string
if pythonVersion == "" {
errs = append(errs, "source.kind 'uv' requires `python_version` — it is a Python-only deployment mode. Node.js-only graphs are not supported.")
}
deps, _ := raw["dependencies"].([]any)
if deps != nil && len(deps) > 0 {
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
}
// Also check if dependencies key exists even if empty array.
if deps == nil {
if rawDeps, exists := raw["dependencies"]; exists && rawDeps != nil {
// dependencies key present but not an array — still flag it
if depsArr, ok := rawDeps.([]any); ok && len(depsArr) > 0 {
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
}
}
}
sourceMap, _ := raw["source"].(map[string]any)
if root, exists := sourceMap["root"]; exists {
rootStr, ok := root.(string)
if !ok {
errs = append(errs, fmt.Sprintf("`source.root` must be a string, got %T.", root))
} else if rootStr == "" {
errs = append(errs, "`source.root` must be a non-empty string. Use `\".\"`.")
}
}
if pkg, exists := sourceMap["package"]; exists {
if pkg != nil {
pkgStr, ok := pkg.(string)
if !ok {
errs = append(errs, "`source.package` must be a non-empty string.")
} else if pkgStr == "" {
errs = append(errs, "`source.package` must be a non-empty string.")
}
}
}
if len(errs) > 0 {
formatted := ""
for i, e := range errs {
formatted += fmt.Sprintf("\n %d. %s", i+1, e)
}
return nil, fmt.Errorf(
"source.kind 'uv' requires a different config shape than dependency-based installs:%s",
formatted)
}
}
// --- legacy project_root / package ---
_, hasProjectRoot := raw["project_root"]
_, hasPackage := raw["package"]
if hasProjectRoot || hasPackage {
return nil, fmt.Errorf(
"Top-level `project_root` and `package` are no longer supported. " +
"Use `source.root` and `source.package` instead.")
}
// --- auth path validation ---
if auth, ok := raw["auth"].(map[string]any); ok {
if authPath, _ := auth["path"].(string); authPath != "" {
if !strings.Contains(authPath, ":") {
return nil, fmt.Errorf(
"Invalid auth.path format: '%s'. "+
"Must be in format './path/to/file.py:attribute_name'",
authPath)
}
}
}
// --- encryption path validation ---
if enc, ok := raw["encryption"].(map[string]any); ok {
if encPath, _ := enc["path"].(string); encPath != "" {
if !strings.Contains(encPath, ":") {
return nil, fmt.Errorf(
"Invalid encryption.path format: '%s'. "+
"Must be in format './path/to/file.py:attribute_name'",
encPath)
}
}
}
// --- http.app path validation ---
if httpConf, ok := raw["http"].(map[string]any); ok {
if app, _ := httpConf["app"].(string); app != "" {
if !strings.Contains(app, ":") {
return nil, fmt.Errorf(
"Invalid http.app format: '%s'. "+
"Must be in format './path/to/file.py:attribute_name'",
app)
}
}
}
// --- keep_pkg_tools validation ---
if kpt := raw["keep_pkg_tools"]; kpt != nil {
validBuildTools := map[string]bool{"pip": true, "setuptools": true, "wheel": true}
switch v := kpt.(type) {
case bool:
// ok
case []any:
for _, item := range v {
tool, ok := item.(string)
if !ok || !validBuildTools[tool] {
return nil, fmt.Errorf(
"Invalid keep_pkg_tools: '%v'. "+
"Must be one of 'pip', 'setuptools', 'wheel'.",
item)
}
}
default:
return nil, fmt.Errorf(
"Invalid keep_pkg_tools: '%v'. "+
"Must be bool or list[str] (with values 'pip', 'setuptools', and/or 'wheel').",
kpt)
}
}
return config, nil
}
// ValidateConfigFile loads a config file, validates it, and returns the result.
func ValidateConfigFile(configPath string) (map[string]any, error) {
raw, err := LoadRawConfigFile(configPath)
if err != nil {
return nil, err
}
return validateConfigFile(configPath, raw)
}
// LoadRawConfigFile loads a config file and requires the top-level JSON value
// to be an object.
func LoadRawConfigFile(configPath string) (map[string]any, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("could not read config file: %w", err)
}
var rawAny any
if err := json.Unmarshal(data, &rawAny); err != nil {
return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error())
}
raw, ok := rawAny.(map[string]any)
if !ok {
return nil, fmt.Errorf(
"Invalid config in %s: top-level JSON value must be an object.",
configPath,
)
}
return raw, nil
}
func validateConfigFile(configPath string, raw map[string]any) (map[string]any, error) {
validated, err := ValidateConfig(raw)
if err != nil {
return nil, err
}
// Check package.json node version if node_version is set.
if nv, _ := validated["node_version"].(string); nv != "" {
dir := filepath.Dir(configPath)
pkgJSONPath := filepath.Join(dir, "package.json")
if info, statErr := os.Stat(pkgJSONPath); statErr == nil && !info.IsDir() {
if pkgErr := validatePackageJSON(pkgJSONPath); pkgErr != nil {
return nil, pkgErr
}
}
}
return validated, nil
}
func validatePackageJSON(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return fmt.Errorf(
"Invalid package.json found in langgraph config directory %s: file is not valid JSON",
path,
)
}
enginesRaw, ok := pkg["engines"]
if !ok {
return nil
}
engines, ok := enginesRaw.(map[string]any)
if !ok {
return nil
}
for k := range engines {
if k != "node" {
keys := make([]string, 0, len(engines))
for ek := range engines {
keys = append(keys, ek)
}
return fmt.Errorf(
"Only 'node' engine is supported in package.json engines. Got engines: %v",
keys)
}
}
if nodeVer, ok := engines["node"].(string); ok && nodeVer != "" {
if strings.Contains(nodeVer, ".") {
return fmt.Errorf(
"Node.js version in package.json engines must be >= %s "+
"(major version only), got '%s'. "+
"Minor/patch versions (like '20.x.y') are not supported to "+
"prevent deployment issues when new Node.js versions are released.",
MinNodeVersion, nodeVer)
}
major, err := strconv.Atoi(nodeVer)
if err == nil {
minMajor, _ := strconv.Atoi(MinNodeVersion)
if major < minMajor {
return fmt.Errorf(
"Node.js version in package.json engines must be >= %s "+
"(major version only), got '%s'. "+
"Minor/patch versions (like '20.x.y') are not supported to "+
"prevent deployment issues when new Node.js versions are released.",
MinNodeVersion, nodeVer)
}
}
}
return nil
}
// GetUnknownKeys returns warnings for unrecognised top-level keys.
func GetUnknownKeys(raw map[string]any) []string {
var unknown []string
for k := range raw {
if !knownConfigKeys[k] {
unknown = append(unknown, k)
}
}
sortStrings(unknown)
var warnings []string
knownList := make([]string, 0, len(knownConfigKeys))
for k := range knownConfigKeys {
knownList = append(knownList, k)
}
for _, key := range unknown {
if close := closestMatch(key, knownList); close != "" {
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' — did you mean '%s'?", key, close))
} else {
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' is not a recognized config field.", key))
}
}
return warnings
}
// closestMatch finds the best match for word among candidates using edit distance.
// Returns "" if no match is close enough (ratio >= 0.6).
func closestMatch(word string, candidates []string) string {
best := ""
bestRatio := 0.6 // minimum threshold
for _, c := range candidates {
ratio := similarity(word, c)
if ratio > bestRatio {
bestRatio = ratio
best = c
}
}
return best
}
// similarity returns a ratio in [0,1] based on Levenshtein distance.
func similarity(a, b string) float64 {
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
if maxLen == 0 {
return 1.0
}
dist := editDistance(a, b)
return 1.0 - float64(dist)/float64(maxLen)
}
// editDistance computes Levenshtein distance between two strings.
func editDistance(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
ins := curr[j-1] + 1
del := prev[j] + 1
sub := prev[j-1] + cost
curr[j] = min3(ins, del, sub)
}
prev, curr = curr, prev
}
return prev[lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// sortStrings sorts a slice of strings in place (simple insertion sort, fine for small n).
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
-568
View File
@@ -1,568 +0,0 @@
package config
import (
"strings"
"testing"
)
// baseConfig returns a minimal valid config map. Tests should copy and modify it.
func baseConfig() map[string]any {
return map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph"},
}
}
// copyMap returns a shallow copy of m.
func copyMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// mustSucceed is a test helper that fails if err is non-nil.
func mustSucceed(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("expected success but got error: %v", err)
}
}
// mustFail is a test helper that fails if err is nil.
func mustFail(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected error but got nil")
}
}
// mustContain checks that err is non-nil and its message contains substr.
func mustContain(t *testing.T, err error, substr string) {
t.Helper()
if err == nil {
t.Fatalf("expected error containing %q but got nil", substr)
}
if !strings.Contains(err.Error(), substr) {
t.Fatalf("expected error to contain %q, got: %s", substr, err.Error())
}
}
func TestValidateConfigValid(t *testing.T) {
t.Run("minimal config", func(t *testing.T) {
raw := baseConfig()
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if pv, _ := result["python_version"].(string); pv != "3.11" {
t.Fatalf("expected python_version '3.11', got %q", pv)
}
if id, _ := result["image_distro"].(string); id != "debian" {
t.Fatalf("expected image_distro 'debian', got %q", id)
}
})
t.Run("full config with all optional fields", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"image_distro": "wolfi",
"pip_installer": "uv",
"dependencies": []any{"langchain", "langgraph"},
"graphs": map[string]any{"agent": "./agent.py:graph"},
"env": map[string]any{"FOO": "bar"},
"dockerfile_lines": []any{"RUN apt-get update"},
"auth": map[string]any{"path": "./auth.py:handler"},
"encryption": map[string]any{"path": "./enc.py:enc"},
"http": map[string]any{"app": "./app.py:app"},
"keep_pkg_tools": true,
"api_version": "0.8",
}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
func TestValidateConfigPythonVersion(t *testing.T) {
validVersions := []string{"3.11", "3.12", "3.13"}
for _, v := range validVersions {
t.Run("valid "+v, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = v
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
t.Run("valid 3.12-slim suffix stripped", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = "3.12-slim"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
tooOld := []string{"3.10", "3.9"}
for _, v := range tooOld {
t.Run("too old "+v, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = v
_, err := ValidateConfig(raw)
mustContain(t, err, "Minimum required version")
})
}
badFormat := []struct {
version string
}{
{"3.11.0"},
{"3"},
{"abc.def"},
}
for _, tc := range badFormat {
t.Run("bad format "+tc.version, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["python_version"] = tc.version
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid Python version format")
})
}
}
func TestValidateConfigNodeVersion(t *testing.T) {
// Need a node graph to trigger node_version validation
nodeBase := func() map[string]any {
return map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph"},
}
}
t.Run("valid 20", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "20"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("valid 22", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "22"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("too old 18", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "18"
_, err := ValidateConfig(raw)
mustContain(t, err, "Minimum required version is 20")
})
t.Run("minor version 20.1", func(t *testing.T) {
raw := nodeBase()
raw["node_version"] = "20.1"
_, err := ValidateConfig(raw)
mustContain(t, err, "major version only")
})
}
func TestValidateConfigGraphs(t *testing.T) {
t.Run("empty graphs", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["graphs"] = map[string]any{}
_, err := ValidateConfig(raw)
mustContain(t, err, "No graphs found")
})
t.Run("missing graphs key", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "No graphs found")
})
}
func TestValidateConfigImageDistro(t *testing.T) {
validDistroTests := []string{"debian", "wolfi", "bookworm"}
for _, d := range validDistroTests {
t.Run("valid "+d, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = d
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
t.Run("bullseye deprecated", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = "bullseye"
_, err := ValidateConfig(raw)
mustContain(t, err, "deprecated")
})
t.Run("invalid ubuntu", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = "ubuntu"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid image_distro")
})
t.Run("invalid alpine", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["image_distro"] = "alpine"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid image_distro")
})
t.Run("default is debian", func(t *testing.T) {
raw := baseConfig()
// no image_distro key
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if id, _ := result["image_distro"].(string); id != "debian" {
t.Fatalf("expected default image_distro 'debian', got %q", id)
}
})
}
func TestValidateConfigPipInstaller(t *testing.T) {
valid := []string{"auto", "pip", "uv"}
for _, pi := range valid {
t.Run("valid "+pi, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["pip_installer"] = pi
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
}
invalid := []string{"conda", "uv_lock"}
for _, pi := range invalid {
t.Run("invalid "+pi, func(t *testing.T) {
raw := copyMap(baseConfig())
raw["pip_installer"] = pi
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid pip_installer")
})
}
}
func TestValidateConfigSource(t *testing.T) {
t.Run("valid uv source with root", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../.."},
}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid source kind poetry", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "poetry"},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid source.kind")
})
t.Run("source as string not object", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["source"] = "not-an-object"
_, err := ValidateConfig(raw)
mustContain(t, err, "`source` must be an object")
})
t.Run("uv source with dependencies", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": ".."},
"dependencies": []any{"langchain"},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "Remove `dependencies`")
})
t.Run("uv source with root as number", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": 123},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "source.root` must be a string")
})
t.Run("uv source with package as number", func(t *testing.T) {
raw := map[string]any{
"python_version": "3.12",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"source": map[string]any{"kind": "uv", "root": "..", "package": 123},
}
_, err := ValidateConfig(raw)
mustContain(t, err, "source.package` must be a non-empty string")
})
}
func TestValidateConfigAPIVersion(t *testing.T) {
t.Run("valid 0.8", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "0.8"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("valid 0.8.1", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "0.8.1"
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid abc", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "abc"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid version format")
})
t.Run("invalid 1.2.3.4 too many parts", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["api_version"] = "1.2.3.4"
_, err := ValidateConfig(raw)
mustContain(t, err, "major or major.minor")
})
}
func TestValidateConfigMutualExclusion(t *testing.T) {
t.Run("both _INTERNAL_docker_tag and api_version", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["_INTERNAL_docker_tag"] = "some-tag"
raw["api_version"] = "0.8"
_, err := ValidateConfig(raw)
mustContain(t, err, "Cannot specify both")
})
}
func TestValidateConfigAuthPath(t *testing.T) {
t.Run("valid auth path with colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["auth"] = map[string]any{"path": "./auth.py:handler"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid auth path without colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["auth"] = map[string]any{"path": "../../examples/my_app.py"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid auth.path format")
})
}
func TestValidateConfigEncryptionPath(t *testing.T) {
t.Run("valid encryption path with colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["encryption"] = map[string]any{"path": "./enc.py:enc"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid encryption path without colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["encryption"] = map[string]any{"path": "./enc.py"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid encryption.path format")
})
}
func TestValidateConfigHTTPApp(t *testing.T) {
t.Run("valid http app with colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["http"] = map[string]any{"app": "./app.py:app"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid http app without colon", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["http"] = map[string]any{"app": "./app.py"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid http.app format")
})
}
func TestValidateConfigKeepPkgTools(t *testing.T) {
t.Run("bool true", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = true
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("valid list", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = []any{"pip", "wheel"}
_, err := ValidateConfig(raw)
mustSucceed(t, err)
})
t.Run("invalid list item", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = []any{"invalid"}
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid keep_pkg_tools")
})
t.Run("invalid string type", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["keep_pkg_tools"] = "string"
_, err := ValidateConfig(raw)
mustContain(t, err, "Invalid keep_pkg_tools")
})
}
func TestValidateConfigLegacyKeys(t *testing.T) {
t.Run("project_root legacy", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["project_root"] = ".."
_, err := ValidateConfig(raw)
mustContain(t, err, "no longer supported")
})
t.Run("package legacy", func(t *testing.T) {
raw := copyMap(baseConfig())
raw["package"] = "foo"
_, err := ValidateConfig(raw)
mustContain(t, err, "no longer supported")
})
}
func TestValidateConfigNodeGraphDetection(t *testing.T) {
t.Run("ts extension auto-sets node_version", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
})
t.Run("js extension auto-sets node_version", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.js:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
})
t.Run("py extension does not set node_version", func(t *testing.T) {
raw := baseConfig()
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "" {
t.Fatalf("expected node_version '', got %q", nv)
}
})
}
func TestGetUnknownKeys(t *testing.T) {
t.Run("typo suggests correction", func(t *testing.T) {
raw := map[string]any{
"grpahs": map[string]any{"agent": "./agent.py:graph"},
"dependencies": []any{"langchain"},
}
warnings := GetUnknownKeys(raw)
found := false
for _, w := range warnings {
if strings.Contains(w, "did you mean 'graphs'") {
found = true
break
}
}
if !found {
t.Fatalf("expected warning suggesting 'graphs', got: %v", warnings)
}
})
t.Run("totally unknown key", func(t *testing.T) {
raw := map[string]any{
"totally_unknown": "value",
"graphs": map[string]any{"agent": "./agent.py:graph"},
"dependencies": []any{"langchain"},
}
warnings := GetUnknownKeys(raw)
found := false
for _, w := range warnings {
if strings.Contains(w, "not a recognized config field") {
found = true
break
}
}
if !found {
t.Fatalf("expected 'not a recognized config field' warning, got: %v", warnings)
}
})
t.Run("only known keys gives no warnings", func(t *testing.T) {
raw := baseConfig()
warnings := GetUnknownKeys(raw)
if len(warnings) != 0 {
t.Fatalf("expected no warnings, got: %v", warnings)
}
})
}
func TestValidateConfigMultiplatform(t *testing.T) {
t.Run("only JS graphs", func(t *testing.T) {
raw := map[string]any{
"graphs": map[string]any{"bot": "./bot.ts:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
if pv, _ := result["python_version"].(string); pv != "" {
t.Fatalf("expected python_version '', got %q", pv)
}
})
t.Run("only Python graphs", func(t *testing.T) {
raw := baseConfig()
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if pv, _ := result["python_version"].(string); pv != "3.11" {
t.Fatalf("expected python_version '3.11', got %q", pv)
}
if nv, _ := result["node_version"].(string); nv != "" {
t.Fatalf("expected node_version '', got %q", nv)
}
})
t.Run("mixed graphs", func(t *testing.T) {
raw := map[string]any{
"dependencies": []any{"langchain"},
"graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"},
}
result, err := ValidateConfig(raw)
mustSucceed(t, err)
if pv, _ := result["python_version"].(string); pv != "3.11" {
t.Fatalf("expected python_version '3.11', got %q", pv)
}
if nv, _ := result["node_version"].(string); nv != "20" {
t.Fatalf("expected node_version '20', got %q", nv)
}
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-804
View File
@@ -1,804 +0,0 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Helper: write a uv-lock workspace fixture
// ---------------------------------------------------------------------------
type workspaceOpts struct {
// Relative directory (within project_root) that holds langgraph.json.
// Default: "deploy/agent"
configRelativeDir string
// Extra TOML appended to the root pyproject.toml (e.g. [tool.uv.sources]).
rootSources string
// Extra TOML appended to the agent pyproject.toml (e.g. [tool.uv.sources]).
agentSources string
// Extra TOML appended to the shared lib pyproject.toml (e.g. [tool.uv] section).
sharedUvConfig string
// Custom agent dependencies list. When nil the default is used.
agentDependencies []string
// Additional files to create: relative-path (from project_root) -> content.
extraFiles map[string]string
}
// writeUvLockWorkspace creates the standard multi-package uv workspace used
// by the Python test_config_to_docker_uv_lock* test suite and returns
// (projectRoot, configPath).
//
// Layout:
//
// workspace/
// pyproject.toml [project] name="workspace-root" + [tool.uv.workspace]
// uv.lock
// apps/agent/ package "agent"
// pyproject.toml
// src/agent/graph.py
// libs/shared/ package "shared"
// pyproject.toml
// src/shared/auth.py
// libs/extra/ package "extra" (not a dep of agent)
// pyproject.toml
// src/extra/graph.py
// deploy/agent/ config directory (configRelativeDir)
// langgraph.json
func writeUvLockWorkspace(t *testing.T, opts workspaceOpts) (string, string) {
t.Helper()
base := t.TempDir()
projectRoot := filepath.Join(base, "workspace")
configRelDir := opts.configRelativeDir
if configRelDir == "" {
configRelDir = "deploy/agent"
}
configDir := filepath.Join(projectRoot, configRelDir)
sharedDir := filepath.Join(projectRoot, "libs", "shared")
extraDir := filepath.Join(projectRoot, "libs", "extra")
deployDir := filepath.Join(projectRoot, "deploy", "agent")
for _, d := range []string{configDir, sharedDir, extraDir, deployDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", d, err)
}
}
// -- root pyproject.toml -------------------------------------------------
rootSources := opts.rootSources
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
"[project]\n"+
"name = \"workspace-root\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[tool.uv.workspace]\n"+
"members = [\"apps/*\", \"libs/*\"]\n"+
"\n"+
rootSources+"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- uv.lock -------------------------------------------------------------
writeFile(t, filepath.Join(projectRoot, "uv.lock"), "# uv lock file\n")
// -- agent pyproject.toml ------------------------------------------------
agentDir := filepath.Join(projectRoot, "apps", "agent")
if err := os.MkdirAll(agentDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
agentDeps := opts.agentDependencies
if agentDeps == nil {
agentDeps = []string{"shared", "httpx>=0.28"}
}
depsList := "[\"" + strings.Join(agentDeps, "\", \"") + "\"]"
agentSources := opts.agentSources
writeFile(t, filepath.Join(agentDir, "pyproject.toml"),
"[project]\n"+
"name = \"agent\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = "+depsList+"\n"+
"\n"+
agentSources+"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- shared pyproject.toml -----------------------------------------------
sharedUvConfig := opts.sharedUvConfig
writeFile(t, filepath.Join(sharedDir, "pyproject.toml"),
"[project]\n"+
"name = \"shared\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = [\"anyio>=4\"]\n"+
"\n"+
sharedUvConfig+"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- extra pyproject.toml ------------------------------------------------
writeFile(t, filepath.Join(extraDir, "pyproject.toml"),
"[project]\n"+
"name = \"extra\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
// -- source files --------------------------------------------------------
writeFile(t, filepath.Join(agentDir, "src", "agent", "graph.py"), "")
writeFile(t, filepath.Join(sharedDir, "src", "shared", "auth.py"), "")
writeFile(t, filepath.Join(extraDir, "src", "extra", "graph.py"), "")
// -- config file ---------------------------------------------------------
configPath := filepath.Join(deployDir, "langgraph.json")
writeFile(t, configPath, "{}\n")
// If configRelativeDir is non-default, also place langgraph.json there.
if configRelDir != "deploy/agent" {
altConfigPath := filepath.Join(configDir, "langgraph.json")
writeFile(t, altConfigPath, "{}\n")
configPath = altConfigPath
}
// -- extra files ---------------------------------------------------------
for relPath, content := range opts.extraFiles {
writeFile(t, filepath.Join(projectRoot, relPath), content)
}
return projectRoot, configPath
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestUvLockBasic(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
// Core commands.
assertContains(t, docker, "uv pip install --system")
assertContains(t, docker,
"uv export --package 'agent' --frozen --no-hashes --no-emit-project --no-emit-workspace")
// Copy project metadata for uv export.
assertContains(t, docker,
"COPY --from=uv-workspace-root pyproject.toml /tmp/uv_export/project/pyproject.toml")
assertContains(t, docker,
"COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock")
// Additional build contexts.
if _, ok := contexts["uv-workspace-root"]; !ok {
t.Fatal("expected 'uv-workspace-root' in additional contexts")
}
// Workspace packages copied.
assertContains(t, docker,
"COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent")
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
// Unrelated member NOT copied.
assertNotContains(t, docker,
"libs/extra /deps/workspace/libs/extra")
// Install order: shared before agent.
assertContains(t, docker, "WORKDIR /deps/workspace/libs/shared")
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent")
sharedInstall := "uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e ."
assertContains(t, docker, sharedInstall)
// Ordering: shared COPY < shared WORKDIR < agent COPY < agent WORKDIR.
sharedCopy := "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
sharedWD := "WORKDIR /deps/workspace/libs/shared"
agentCopy := "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
agentWD := "WORKDIR /deps/workspace/apps/agent"
if strings.Index(docker, sharedCopy) >= strings.Index(docker, sharedWD) {
t.Error("shared COPY should appear before shared WORKDIR")
}
if strings.Index(docker, sharedWD) >= strings.Index(docker, agentCopy) {
t.Error("shared WORKDIR should appear before agent COPY")
}
if strings.Index(docker, sharedWD) >= strings.Index(docker, agentWD) {
t.Error("shared WORKDIR should appear before agent WORKDIR")
}
// No legacy dep-loop patterns.
assertNotContains(t, docker, "for dep in /deps/*")
assertNotContains(t, docker, "# -- Installing workspace packages --")
assertContains(t, docker, "WORKDIR /tmp/uv_export/project")
assertNotContains(t, docker, "RUN cd ")
// Rewritten paths in env vars (Go JSON uses compact format without spaces).
assertContains(t, docker,
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
assertContains(t, docker,
`"/deps/workspace/apps/agent/src/agent/graph.py:graph"`)
// Cleanup.
assertContains(t, docker, "rm /usr/bin/uv /usr/bin/uvx")
}
func TestUvLockHonorsRootWorkspaceSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertContains(t, docker,
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
}
func TestUvLockIgnoresUnrelatedWorkspacePackageSources(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
// Add badlib with [tool.uv.sources] pointing outside (an unrelated member).
badlibDir := filepath.Join(projectRoot, "libs", "badlib")
writeFile(t, filepath.Join(badlibDir, "pyproject.toml"),
"[project]\n"+
"name = \"badlib\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[tool.uv.sources]\n"+
"outside = { path = \"../outside\" }\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertNotContains(t, docker,
"COPY --from=uv-workspace-root libs/badlib /deps/workspace/libs/badlib")
}
func TestUvLockIgnoresUnrelatedRootSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nunused = { path = \"libs/extra\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertNotContains(t, docker,
"COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra")
}
func TestUvLockValidatesRootPathSourcesRelativeToProjectRoot(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { path = \"../outside\", editable = true }",
})
// Create the outside directory the source points to.
outsideDir := filepath.Join(filepath.Dir(projectRoot), "outside")
writeFile(t, filepath.Join(outsideDir, "pyproject.toml"),
"[project]\n"+
"name = \"shared\"\n"+
"version = \"0.1.0\"\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for path outside project_root")
}
assertContains(t, err.Error(), "outside project_root")
}
func TestUvLockRequiresExplicitWorkspaceSources(t *testing.T) {
// No agent_sources or root_sources => shared is NOT a workspace dep.
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error because shared is not an explicit workspace source")
}
assertContains(t, err.Error(), "not inside the target package 'agent'")
}
func TestUvLockAcceptsPathWorkspaceSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { path = \"../../libs/shared\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
}
func TestUvLockRejectsPackageFalseWorkspaceDependency(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
sharedUvConfig: "[tool.uv]\npackage = false",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for package = false workspace dependency")
}
assertContains(t, err.Error(), "tool.uv.package = false")
}
func TestUvLockAcceptsRootPathWorkspaceSources(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
rootSources: "[tool.uv.sources]\nshared = { path = \"libs/shared\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"auth": map[string]any{"path": "../../libs/shared/src/shared/auth.py:create_auth"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared")
assertContains(t, docker,
`"/deps/workspace/libs/shared/src/shared/auth.py:create_auth"`)
}
func TestUvLockRejectsMismatchedPathWorkspaceSources(t *testing.T) {
// agent sources point to libs/extra with the name "shared" -> mismatch.
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { path = \"../../libs/extra\", editable = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for mismatched path workspace sources")
}
assertContains(t, err.Error(), "dependency name and the workspace package name must match")
}
func TestUvLockDetectsJsPmFromTargetPackageRoot(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
agentDir := filepath.Join(projectRoot, "apps", "agent")
writeFile(t, filepath.Join(agentDir, "package.json"), "{\"packageManager\":\"pnpm@9.0.0\"}\n")
writeFile(t, filepath.Join(agentDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
writeFile(t, filepath.Join(agentDir, "ui.tsx"), "export const ui = null;\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"ui": map[string]any{"agent": "../../apps/agent/ui.tsx"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent")
assertContains(t, docker,
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts")
assertContains(t, docker, `/deps/workspace/apps/agent/ui.tsx`)
}
func TestUvLockUsesWorkdirForJsInstallWithSpecialChars(t *testing.T) {
projectRoot, _ := writeUvLockWorkspace(t, workspaceOpts{
configRelativeDir: "apps/agent;echo pwned",
})
// The custom configRelativeDir creates its own langgraph.json.
// We need to place the agent pyproject.toml at this directory.
agentDir := filepath.Join(projectRoot, "apps", "agent;echo pwned")
writeFile(t, filepath.Join(agentDir, "package.json"), "{\"packageManager\":\"pnpm@9.0.0\"}\n")
writeFile(t, filepath.Join(agentDir, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n")
writeFile(t, filepath.Join(agentDir, "ui.tsx"), "export const ui = null;\n")
writeFile(t, filepath.Join(agentDir, "pyproject.toml"),
"[project]\n"+
"name = \"agent\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = [\"shared\", \"httpx>=0.28\"]\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
writeFile(t, filepath.Join(agentDir, "src", "agent", "graph.py"), "")
// Remove the default apps/agent so there's no duplicate package name.
if err := os.RemoveAll(filepath.Join(projectRoot, "apps", "agent")); err != nil {
t.Fatal(err)
}
// Update workspace members to include the special-char directory.
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
"[project]\n"+
"name = \"workspace-root\"\n"+
"version = \"0.1.0\"\n"+
"\n"+
"[tool.uv.workspace]\n"+
"members = [\"apps/*\", \"libs/*\"]\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
configPath := filepath.Join(agentDir, "langgraph.json")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "./src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
"ui": map[string]any{"agent": "./ui.tsx"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker, "WORKDIR /deps/workspace/apps/agent;echo pwned")
assertContains(t, docker,
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts")
assertNotContains(t, docker, "RUN cd /deps/workspace/apps/agent;echo pwned")
}
func TestUvLockSupportsSingleUvProjectRoot(t *testing.T) {
base := t.TempDir()
projectRoot := filepath.Join(base, "single")
if err := os.MkdirAll(projectRoot, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(projectRoot, "uv.lock"), "# uv lock file\n")
writeFile(t, filepath.Join(projectRoot, "pyproject.toml"),
"[project]\n"+
"name = \"single-app\"\n"+
"version = \"0.1.0\"\n"+
"dependencies = [\"httpx>=0.28\"]\n"+
"\n"+
"[build-system]\n"+
"requires = [\"setuptools>=61\"]\n"+
"build-backend = \"setuptools.build_meta\"\n")
configPath := filepath.Join(projectRoot, "langgraph.json")
writeFile(t, configPath, "{}\n")
writeFile(t, filepath.Join(projectRoot, "src", "agent.py"), "graph = object()\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "./src/agent.py:graph"},
"source": map[string]any{"kind": "uv"},
})
docker, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
assertContains(t, docker,
"uv export --package 'single-app' --frozen --no-hashes --no-emit-project --no-emit-workspace")
assertContains(t, docker, `"/deps/workspace/src/agent.py:graph"`)
if len(contexts) != 0 {
t.Fatalf("expected no additional contexts for single-project root, got %d: %v",
len(contexts), contexts)
}
}
func TestUvLockRejectsInvalidSourcePackageType(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
// Override the validated source.package to an integer.
cfg["source"].(map[string]any)["package"] = 123
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for non-string source.package")
}
assertContains(t, err.Error(), "`source.package` must be a non-empty string")
}
func TestUvLockRejectsPathsOutsideTargetClosure(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentSources: "[tool.uv.sources]\nshared = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
// Graph path points into libs/extra which is NOT a dependency of agent.
"agent": "../../libs/extra/src/extra/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for graph path outside target closure")
}
assertContains(t, err.Error(), "not inside the target package 'agent'")
}
func TestUvLockRejectsUnrelatedMemberWhenRootInClosure(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentDependencies: []string{"workspace-root", "shared", "httpx>=0.28"},
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
agentSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
// extra is a workspace member but NOT a dependency of agent.
"agent": "../../libs/extra/src/extra/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for unrelated member when root is in closure")
}
assertContains(t, err.Error(), "not inside the target package 'agent'")
}
func TestUvLockRootPackageCopySkipsUnrelatedMembers(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{
agentDependencies: []string{"workspace-root", "shared", "httpx>=0.28"},
rootSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
agentSources: "[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
})
// Add files in the workspace root package itself.
writeFile(t, filepath.Join(projectRoot, "src", "workspace_root", "__init__.py"), "__all__ = []\n")
writeFile(t, filepath.Join(projectRoot, "README.md"), "workspace root package\n")
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{
"agent": "../../apps/agent/src/agent/graph.py:graph",
},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
docker, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err != nil {
t.Fatalf("ConfigToDocker: %v", err)
}
// Should NOT do a blanket COPY of the whole workspace root.
assertNotContains(t, docker, "COPY --from=uv-workspace-root . /deps/workspace")
// Should copy specific entries.
assertContains(t, docker, "COPY --from=uv-workspace-root src /deps/workspace/src")
assertContains(t, docker, "COPY --from=uv-workspace-root README.md /deps/workspace/README.md")
// Should NOT copy unrelated member dirs.
assertNotContains(t, docker,
"COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra")
// Should install workspace root from /deps/workspace.
assertContains(t, docker, "WORKDIR /deps/workspace")
assertContains(t, docker,
"uv pip install --system --no-cache-dir -c /api/constraints.txt --no-deps -e .")
}
func TestUvLockMissingLockfile(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{})
// Remove uv.lock.
if err := os.Remove(filepath.Join(projectRoot, "uv.lock")); err != nil {
t.Fatal(err)
}
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for missing uv.lock")
}
assertContains(t, err.Error(), "No uv.lock found")
}
func TestUvLockMissingPyproject(t *testing.T) {
projectRoot, configPath := writeUvLockWorkspace(t, workspaceOpts{})
// Remove root pyproject.toml.
if err := os.Remove(filepath.Join(projectRoot, "pyproject.toml")); err != nil {
t.Fatal(err)
}
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.47",
})
if err == nil {
t.Fatal("expected error for missing pyproject.toml")
}
assertContains(t, err.Error(), "No pyproject.toml found")
}
func TestUvLockOldImage(t *testing.T) {
_, configPath := writeUvLockWorkspace(t, workspaceOpts{})
cfg := mustValidate(t, map[string]any{
"python_version": "3.11",
"graphs": map[string]any{"agent": "../../apps/agent/src/agent/graph.py:graph"},
"source": map[string]any{"kind": "uv", "root": "../..", "package": "agent"},
})
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
BaseImage: "langchain/langgraph-api:0.2.46",
})
if err == nil {
t.Fatal("expected error for old image without uv support")
}
assertContains(t, err.Error(), "requires a base image with uv support")
}
-272
View File
@@ -1,272 +0,0 @@
// Package deploy provides an HTTP client for the LangGraph host backend
// deployment service.
package deploy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Secret represents a name/value pair sent as a deployment secret.
type Secret struct {
Name string `json:"name"`
Value string `json:"value"`
}
// HostBackendClient is a minimal JSON HTTP client for the host backend
// deployment service.
type HostBackendClient struct {
BaseURL string
APIKey string
TenantID string
client *http.Client
}
// retryTransport wraps an http.RoundTripper and retries failed requests.
type retryTransport struct {
base http.RoundTripper
retries int
}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
// We need to buffer the body so we can replay it on retries.
var bodyBytes []byte
if req.Body != nil {
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body.Close()
}
for attempt := 0; attempt <= t.retries; attempt++ {
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
resp, err = t.base.RoundTrip(req)
if err == nil {
return resp, nil
}
// Only retry on transport-level errors; do not retry on HTTP error
// status codes (the caller handles those).
}
return resp, err
}
// NewClient creates a new HostBackendClient. The baseURL is stripped of any
// trailing slash. The underlying http.Client uses a 30-second timeout and
// retries transport-level failures up to 3 times.
func NewClient(baseURL, apiKey string) *HostBackendClient {
return &HostBackendClient{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
TenantID: "",
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &retryTransport{
base: http.DefaultTransport,
retries: 3,
},
},
}
}
// request executes an HTTP request against the host backend and returns the
// parsed JSON response. It attaches required headers and handles errors.
func (c *HostBackendClient) request(method, path string, payload map[string]any, params map[string]string) (map[string]any, error) {
fullURL := c.BaseURL + path
// Append query parameters.
if len(params) > 0 {
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
fullURL += "?" + q.Encode()
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshalling request payload: %w", err)
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, body)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.TenantID != "" {
req.Header.Set("X-Tenant-ID", c.TenantID)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response from %s: %w", path, err)
}
if resp.StatusCode >= 400 {
detail := string(respBody)
if detail == "" {
detail = fmt.Sprintf("%d", resp.StatusCode)
}
return nil, fmt.Errorf("%s %s failed with status %d: %s", method, path, resp.StatusCode, detail)
}
if len(respBody) == 0 {
return nil, nil
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to decode response from %s: %w", path, err)
}
return result, nil
}
// requestNoBody is a convenience wrapper for requests that return no parsed body.
func (c *HostBackendClient) requestNoBody(method, path string, payload map[string]any, params map[string]string) error {
_, err := c.request(method, path, payload, params)
return err
}
// CreateDeployment creates a new deployment.
func (c *HostBackendClient) CreateDeployment(name, deploymentType, source string, configPath string, secrets []Secret) (map[string]any, error) {
sourceRevisionConfig := map[string]any{}
if source == "internal_source" && configPath != "" {
sourceRevisionConfig["langgraph_config_path"] = configPath
}
payload := map[string]any{
"name": name,
"source": source,
"source_config": map[string]any{"deployment_type": deploymentType},
"source_revision_config": sourceRevisionConfig,
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("POST", "/v2/deployments", payload, nil)
}
// ListDeployments lists deployments, optionally filtering by name.
func (c *HostBackendClient) ListDeployments(nameContains string) (map[string]any, error) {
params := map[string]string{"name_contains": nameContains}
return c.request("GET", "/v2/deployments", nil, params)
}
// GetDeployment retrieves a single deployment by ID.
func (c *HostBackendClient) GetDeployment(deploymentID string) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
}
// DeleteDeployment deletes a deployment by ID.
func (c *HostBackendClient) DeleteDeployment(deploymentID string) error {
return c.requestNoBody("DELETE", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil)
}
// RequestPushToken requests a push token for a deployment.
func (c *HostBackendClient) RequestPushToken(deploymentID string) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/push-token", deploymentID), nil, nil)
}
// RequestUploadURL gets a signed URL for uploading the source tarball.
func (c *HostBackendClient) RequestUploadURL(deploymentID string) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v2/deployments/%s/upload-url", deploymentID), nil, nil)
}
// UpdateDeployment triggers a new revision using a pre-pushed Docker image.
func (c *HostBackendClient) UpdateDeployment(deploymentID, imageURI string, secrets []Secret) (map[string]any, error) {
payload := map[string]any{
"revision_source": "internal_docker",
"source_revision_config": map[string]any{"image_uri": imageURI},
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
}
// UpdateDeploymentInternalSource triggers a remote-build revision using an
// uploaded source tarball.
func (c *HostBackendClient) UpdateDeploymentInternalSource(
deploymentID, sourceTarballPath, configPath string,
secrets []Secret,
installCommand, buildCommand string,
) (map[string]any, error) {
payload := map[string]any{
"revision_source": "internal_source",
"source_revision_config": map[string]any{
"source_tarball_path": sourceTarballPath,
"langgraph_config_path": configPath,
},
}
sourceConfig := map[string]any{}
if installCommand != "" {
sourceConfig["install_command"] = installCommand
}
if buildCommand != "" {
sourceConfig["build_command"] = buildCommand
}
if len(sourceConfig) > 0 {
payload["source_config"] = sourceConfig
}
if secrets != nil {
payload["secrets"] = secrets
}
return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil)
}
// ListRevisions lists revisions for a deployment.
func (c *HostBackendClient) ListRevisions(deploymentID string, limit int) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions", deploymentID), nil, map[string]string{
"limit": fmt.Sprintf("%d", limit),
})
}
// GetRevision retrieves a single revision.
func (c *HostBackendClient) GetRevision(deploymentID, revisionID string) (map[string]any, error) {
return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions/%s", deploymentID, revisionID), nil, nil)
}
// GetBuildLogs retrieves build logs for a revision.
func (c *HostBackendClient) GetBuildLogs(projectID, revisionID string, payload map[string]any) (map[string]any, error) {
return c.request("POST", fmt.Sprintf("/v1/projects/%s/revisions/%s/build_logs", projectID, revisionID), payload, nil)
}
// GetDeployLogs retrieves deploy logs. If revisionID is non-empty, it is
// included in the path to scope the logs.
func (c *HostBackendClient) GetDeployLogs(projectID string, payload map[string]any, revisionID string) (map[string]any, error) {
var path string
if revisionID != "" {
path = fmt.Sprintf("/v1/projects/%s/revisions/%s/deploy_logs", projectID, revisionID)
} else {
path = fmt.Sprintf("/v1/projects/%s/deploy_logs", projectID)
}
return c.request("POST", path, payload, nil)
}
-306
View File
@@ -1,306 +0,0 @@
package deploy
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// APIKeyEnvNames lists the environment variable names checked (in order) when
// resolving a LangSmith / LangGraph API key.
var APIKeyEnvNames = []string{
"LANGGRAPH_HOST_API_KEY",
"LANGSMITH_API_KEY",
"LANGCHAIN_API_KEY",
}
// DefaultHostURL is the default host backend URL.
const DefaultHostURL = "https://api.host.langchain.com"
// reservedEnvVars contains environment variable names that must not be sent as
// deployment secrets. The set mirrors the Python CLI's RESERVED_ENV_VARS.
var reservedEnvVars = map[string]bool{
// LANGCHAIN_RESERVED_ENV_VARS from host-backend
"LANGCHAIN_TRACING_V2": true,
"LANGSMITH_TRACING_V2": true,
"LANGCHAIN_ENDPOINT": true,
"LANGCHAIN_PROJECT": true,
"LANGSMITH_PROJECT": true,
"LANGSMITH_LANGGRAPH_GIT_REPO": true,
"LANGGRAPH_GIT_REPO_PATH": true,
"LANGCHAIN_API_KEY": true,
"LANGSMITH_CONTROL_PLANE_API_KEY": true,
"POSTGRES_URI": true,
"POSTGRES_PASSWORD": true,
"DATABASE_URI": true,
"LANGSMITH_LANGGRAPH_GIT_REF": true,
"LANGSMITH_LANGGRAPH_GIT_REF_SHA": true,
"LANGGRAPH_AUTH_TYPE": true,
"LANGSMITH_AUTH_ENDPOINT": true,
"LANGSMITH_TENANT_ID": true,
"LANGSMITH_AUTH_VERIFY_TENANT_ID": true,
"LANGSMITH_HOST_PROJECT_ID": true,
"LANGSMITH_HOST_PROJECT_NAME": true,
"LANGSMITH_HOST_REVISION_ID": true,
"LOG_JSON": true,
"LOG_DICT_TRACEBACKS": true,
"REDIS_URI": true,
"LANGCHAIN_CALLBACKS_BACKGROUND": true,
"DD_TRACE_PSYCOPG_ENABLED": true,
"DD_TRACE_REDIS_ENABLED": true,
"LANGSMITH_DEPLOYMENT_NAME": true,
"LANGGRAPH_CLOUD_LICENSE_KEY": true,
// ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted)
"LANGSMITH_API_KEY": true,
"LANGSMITH_ENDPOINT": true,
"POSTGRES_URI_CUSTOM": true,
"REDIS_URI_CUSTOM": true,
"PATH": true,
"PORT": true,
"MOUNT_PREFIX": true,
"LSD_ENV": true,
"LSD_DD_API_KEY": true,
"LSD_DD_ENDPOINT": true,
"LSD_DEPLOYMENT_TYPE": true,
}
var (
invalidImageNameChars = regexp.MustCompile(`[^a-z0-9._-]+`)
validImageTag = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
)
// NormalizeImageName sanitizes a deployment/directory name into a valid Docker
// repository name. Invalid characters are replaced with hyphens and the result
// is lowercased. Returns "app" if the result would be empty.
func NormalizeImageName(name string) string {
if name == "" {
return "app"
}
slug := invalidImageNameChars.ReplaceAllString(strings.ToLower(name), "-")
slug = strings.TrimLeft(slug, "-.")
slug = strings.TrimRight(slug, "-.")
if slug == "" {
return "app"
}
return slug
}
// NormalizeImageTag validates and returns a Docker image tag. Tags may only
// contain [A-Za-z0-9_.-]. Defaults to "latest" when empty.
func NormalizeImageTag(tag string) (string, error) {
if tag == "" {
return "latest", nil
}
if !validImageTag.MatchString(tag) {
return "", fmt.Errorf("image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'")
}
return tag, nil
}
// ResolveAPIKey resolves an API key by checking (in order): the explicit flag
// value, the provided envVars map, and the process environment. Returns an
// empty string if no key is found (the caller should prompt the user).
func ResolveAPIKey(flagValue string, envVars map[string]string) string {
if flagValue != "" {
return flagValue
}
for _, keyName := range APIKeyEnvNames {
if envVars != nil {
if v, ok := envVars[keyName]; ok && v != "" {
return v
}
}
if v := os.Getenv(keyName); v != "" {
return v
}
}
return ""
}
// ParseEnvFromConfig resolves environment variables from the langgraph.json
// config. If the "env" field is a dict (map), those values are used directly.
// If it is a string, it is treated as a path to a .env file (resolved relative
// to the config file's directory). Otherwise, a .env file in the config
// directory is attempted as a fallback.
func ParseEnvFromConfig(configJSON map[string]any, configPath string) map[string]string {
envField, ok := configJSON["env"]
if !ok {
// Fallback: try .env in config dir.
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
}
// If env is a dict (map[string]any), convert to map[string]string.
if envMap, ok := envField.(map[string]any); ok && len(envMap) > 0 {
result := make(map[string]string, len(envMap))
for k, v := range envMap {
result[k] = fmt.Sprintf("%v", v)
}
return result
}
// If env is a string path, parse that .env file.
if envStr, ok := envField.(string); ok && envStr != "" {
envPath := filepath.Join(filepath.Dir(configPath), envStr)
absPath, err := filepath.Abs(envPath)
if err != nil {
return map[string]string{}
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return map[string]string{}
}
return parseDotEnvFile(absPath)
}
// Fallback: try .env in config dir.
return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env"))
}
// parseDotEnvFile reads a .env file and returns its key-value pairs. Lines
// starting with # are treated as comments. Empty values are skipped.
func parseDotEnvFile(path string) map[string]string {
f, err := os.Open(path)
if err != nil {
return map[string]string{}
}
defer f.Close()
result := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
// Strip surrounding quotes if present.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
if key != "" && value != "" {
result[key] = value
}
}
return result
}
// FindDeploymentIDByName lists deployments matching the given name and returns
// the ID of the first exact match. Returns an empty string (and no error) if
// no exact match is found.
func FindDeploymentIDByName(client *HostBackendClient, name string) (string, error) {
if name == "" {
return "", nil
}
existing, err := client.ListDeployments(name)
if err != nil {
return "", err
}
resources, ok := existing["resources"]
if !ok {
return "", nil
}
resourceList, ok := resources.([]any)
if !ok {
return "", nil
}
for _, item := range resourceList {
dep, ok := item.(map[string]any)
if !ok {
continue
}
depName, _ := dep["name"].(string)
if depName == name {
if id, ok := dep["id"]; ok {
return fmt.Sprintf("%v", id), nil
}
}
}
return "", nil
}
// ValidateDeploymentSelector ensures at least one of deploymentID or name is
// provided.
func ValidateDeploymentSelector(deploymentID, name string) error {
if deploymentID != "" {
return nil
}
if name == "" {
return fmt.Errorf("either --deployment-id or --name is required")
}
return nil
}
// ResolvedReservedEnvVars returns the set of reserved environment variable
// names that must not be sent as deployment secrets.
func ResolvedReservedEnvVars() map[string]bool {
// Return a copy to prevent callers from mutating the package-level map.
result := make(map[string]bool, len(reservedEnvVars))
for k, v := range reservedEnvVars {
result[k] = v
}
return result
}
// TerminalStatuses are deployment statuses that indicate completion.
var TerminalStatuses = map[string]bool{
"DEPLOYED": true,
"CREATE_FAILED": true,
"BUILD_FAILED": true,
"DEPLOY_FAILED": true,
"SKIPPED": true,
}
// PollDeploymentStatus polls a deployment until it reaches a terminal status or times out.
func PollDeploymentStatus(client *HostBackendClient, deploymentID string, timeoutSeconds, pollIntervalSeconds int, onStatus func(string)) (string, error) {
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
interval := time.Duration(pollIntervalSeconds) * time.Second
for time.Now().Before(deadline) {
resp, err := client.ListRevisions(deploymentID, 1)
if err != nil {
return "", err
}
revisions, _ := resp["revisions"].([]any)
if len(revisions) == 0 {
time.Sleep(interval)
continue
}
rev, _ := revisions[0].(map[string]any)
status, _ := rev["status"].(string)
if onStatus != nil {
onStatus(status)
}
if TerminalStatuses[status] {
return status, nil
}
time.Sleep(interval)
}
return "", fmt.Errorf("deployment timed out after %d seconds", timeoutSeconds)
}
// SecretsFromEnv converts an env var map into a Secret slice, filtering out
// reserved variable names and empty values.
func SecretsFromEnv(envVars map[string]string) []Secret {
var secrets []Secret
for name, value := range envVars {
if reservedEnvVars[name] {
continue
}
if value == "" {
continue
}
secrets = append(secrets, Secret{Name: name, Value: value})
}
return secrets
}
-154
View File
@@ -1,154 +0,0 @@
package deploy
import (
"os"
"sort"
"testing"
)
func TestNormalizeImageName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"MyApp", "myapp"},
{"", "app"},
{"!!!", "app"},
{"my-app", "my-app"},
{"My Cool App", "my-cool-app"},
{"..leading-dots", "leading-dots"},
{"trailing-dots..", "trailing-dots"},
{"hello_world.v2", "hello_world.v2"},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got := NormalizeImageName(tc.input)
if got != tc.want {
t.Errorf("NormalizeImageName(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestNormalizeImageTag(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{"v1.2.3", "v1.2.3", false},
{"", "latest", false},
{"has space", "", true},
{"valid_tag-1.0", "valid_tag-1.0", false},
{"tag/slash", "", true},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := NormalizeImageTag(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("NormalizeImageTag(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Errorf("NormalizeImageTag(%q) unexpected error: %v", tc.input, err)
return
}
if got != tc.want {
t.Errorf("NormalizeImageTag(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestValidateDeploymentSelector(t *testing.T) {
tests := []struct {
name string
deploymentID string
depName string
wantErr bool
}{
{"both empty", "", "", true},
{"id set", "abc-123", "", false},
{"name set", "", "my-deploy", false},
{"both set", "abc-123", "my-deploy", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateDeploymentSelector(tc.deploymentID, tc.depName)
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestSecretsFromEnv(t *testing.T) {
envVars := map[string]string{
"MY_SECRET": "value1",
"ANOTHER_SECRET": "value2",
"LANGCHAIN_API_KEY": "should-be-filtered",
"POSTGRES_URI": "should-be-filtered",
"EMPTY_VAR": "",
}
secrets := SecretsFromEnv(envVars)
// Sort for deterministic comparison
sort.Slice(secrets, func(i, j int) bool {
return secrets[i].Name < secrets[j].Name
})
if len(secrets) != 2 {
t.Fatalf("expected 2 secrets, got %d: %+v", len(secrets), secrets)
}
if secrets[0].Name != "ANOTHER_SECRET" || secrets[0].Value != "value2" {
t.Errorf("unexpected secret[0]: %+v", secrets[0])
}
if secrets[1].Name != "MY_SECRET" || secrets[1].Value != "value1" {
t.Errorf("unexpected secret[1]: %+v", secrets[1])
}
}
func TestResolveAPIKey(t *testing.T) {
// Flag value takes precedence
got := ResolveAPIKey("flag-key", map[string]string{"LANGSMITH_API_KEY": "env-key"})
if got != "flag-key" {
t.Errorf("expected flag-key, got %q", got)
}
// envVars map is checked next
got = ResolveAPIKey("", map[string]string{"LANGSMITH_API_KEY": "env-key"})
if got != "env-key" {
t.Errorf("expected env-key, got %q", got)
}
// os.Getenv fallback
os.Setenv("LANGSMITH_API_KEY", "os-env-key")
defer os.Unsetenv("LANGSMITH_API_KEY")
got = ResolveAPIKey("", nil)
if got != "os-env-key" {
t.Errorf("expected os-env-key, got %q", got)
}
// Flag still takes precedence over os env
got = ResolveAPIKey("flag-value", nil)
if got != "flag-value" {
t.Errorf("expected flag-value, got %q", got)
}
// Empty everything returns empty
os.Unsetenv("LANGSMITH_API_KEY")
os.Unsetenv("LANGGRAPH_HOST_API_KEY")
os.Unsetenv("LANGCHAIN_API_KEY")
got = ResolveAPIKey("", nil)
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
-529
View File
@@ -1,529 +0,0 @@
// Package docker provides Docker compose generation, capability detection,
// and image building for the LangGraph CLI.
package docker
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/langchain-ai/langgraph/libs/cli/internal/config"
)
// DefaultPostgresURI is the default connection string used when no custom
// Postgres URI is provided.
const DefaultPostgresURI = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
// Version represents a semantic version with major, minor, and patch components.
type Version struct {
Major, Minor, Patch int
}
// GreaterOrEqual returns true if v >= other.
func (v Version) GreaterOrEqual(other Version) bool {
if v.Major != other.Major {
return v.Major > other.Major
}
if v.Minor != other.Minor {
return v.Minor > other.Minor
}
return v.Patch >= other.Patch
}
// DockerCapabilities describes the Docker environment available on the host.
type DockerCapabilities struct {
VersionDocker Version
VersionCompose Version
HealthcheckStartInterval bool
ComposeType string // "plugin" or "standalone"
}
// ComposeOpts configures the generated docker-compose YAML.
type ComposeOpts struct {
Port int
DebuggerPort int // 0 means no debugger
DebuggerBaseURL string // optional base URL for the debugger
PostgresURI string // empty means use DefaultPostgresURI
Image string // pre-built image name
BaseImage string
APIVersion string
EngineRuntimeMode string // "combined_queue_worker" or "distributed"
}
// BuildImageOpts configures docker image building.
type BuildImageOpts struct {
ConfigPath string
ConfigJSON map[string]any
BaseImage string
APIVersion string
Pull bool
Tag string
Passthrough []string
InstallCommand string
BuildCommand string
DockerCommand []string // default: ["docker", "build"]
ExtraFlags []string
Verbose bool
}
// OrderedMap preserves insertion order for map keys.
type OrderedMap struct {
Keys []string
Values map[string]any
}
// NewOrderedMap creates an empty OrderedMap.
func NewOrderedMap() *OrderedMap {
return &OrderedMap{
Values: make(map[string]any),
}
}
// Set adds or updates a key-value pair, preserving insertion order.
func (om *OrderedMap) Set(key string, value any) {
if _, exists := om.Values[key]; !exists {
om.Keys = append(om.Keys, key)
}
om.Values[key] = value
}
// Get retrieves the value for a key.
func (om *OrderedMap) Get(key string) (any, bool) {
v, ok := om.Values[key]
return v, ok
}
// ---------------------------------------------------------------------------
// ParseVersion
// ---------------------------------------------------------------------------
// ParseVersion parses a version string like "1.2.3", "v1.2.3-alpha+build",
// "1.2", or "1" into a Version.
func ParseVersion(version string) Version {
parts := strings.SplitN(version, ".", 3)
major := "0"
minor := "0"
patch := "0"
switch len(parts) {
case 1:
major = parts[0]
case 2:
major = parts[0]
minor = parts[1]
default:
major = parts[0]
minor = parts[1]
patch = parts[2]
}
// Strip "v" prefix from major
major = strings.TrimPrefix(major, "v")
// Strip "-" and "+" suffixes from patch
if idx := strings.IndexAny(patch, "-+"); idx >= 0 {
patch = patch[:idx]
}
majorInt, _ := strconv.Atoi(major)
minorInt, _ := strconv.Atoi(minor)
patchInt, _ := strconv.Atoi(patch)
return Version{Major: majorInt, Minor: minorInt, Patch: patchInt}
}
// ---------------------------------------------------------------------------
// CanBuildLocally
// ---------------------------------------------------------------------------
// CanBuildLocally checks whether local deployment builds can run on this machine.
// It returns (ok, errorMessage). If ok is true, errorMessage is empty.
func CanBuildLocally() (bool, string) {
if _, err := exec.LookPath("docker"); err != nil {
return false, "Docker is required but not installed.\n" +
"Install Docker Desktop: https://docs.docker.com/get-docker/"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "info")
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
return false, "Docker is installed but not running.\nStart Docker and try again."
}
if runtime.GOARCH != "amd64" {
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
buildx := exec.CommandContext(ctx2, "docker", "buildx", "version")
buildx.Stdout = nil
buildx.Stderr = nil
if err := buildx.Run(); err != nil {
arch := runtime.GOARCH
// Try to match Python's platform.machine() naming for the error message
if arch == "arm64" {
arch = "aarch64"
}
return false, "Docker Buildx is required but not installed.\n" +
"Your machine architecture (" + arch + ") requires Buildx to cross-compile images for linux/amd64.\n" +
"Install Buildx: https://docs.docker.com/build/install-buildx/"
}
}
return true, ""
}
// ---------------------------------------------------------------------------
// CheckCapabilities
// ---------------------------------------------------------------------------
// CheckCapabilities detects the Docker and Docker Compose versions available
// on the host and returns a DockerCapabilities describing them.
func CheckCapabilities() (*DockerCapabilities, error) {
if _, err := exec.LookPath("docker"); err != nil {
return nil, fmt.Errorf("Docker not installed")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "info", "-f", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("Docker not installed or not running")
}
var info map[string]any
if err := json.Unmarshal(out, &info); err != nil {
return nil, fmt.Errorf("Docker not installed or not running")
}
serverVersion, _ := info["ServerVersion"].(string)
if serverVersion == "" {
return nil, fmt.Errorf("Docker not running")
}
// Try to find compose plugin
var composeVersionStr string
composeType := "plugin"
found := false
if clientInfo, ok := info["ClientInfo"].(map[string]any); ok {
if plugins, ok := clientInfo["Plugins"].([]any); ok {
for _, p := range plugins {
pm, ok := p.(map[string]any)
if !ok {
continue
}
name, _ := pm["Name"].(string)
if name == "compose" {
composeVersionStr, _ = pm["Version"].(string)
found = true
break
}
}
}
}
if !found {
// Fall back to standalone docker-compose
if _, err := exec.LookPath("docker-compose"); err != nil {
return nil, fmt.Errorf("Docker Compose not installed")
}
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel2()
out2, err := exec.CommandContext(ctx2, "docker-compose", "--version", "--short").Output()
if err != nil {
return nil, fmt.Errorf("Docker Compose not installed")
}
composeVersionStr = strings.TrimSpace(string(out2))
composeType = "standalone"
}
dockerVersion := ParseVersion(serverVersion)
composeVersion := ParseVersion(composeVersionStr)
return &DockerCapabilities{
VersionDocker: dockerVersion,
VersionCompose: composeVersion,
HealthcheckStartInterval: dockerVersion.GreaterOrEqual(Version{25, 0, 0}),
ComposeType: composeType,
}, nil
}
// ---------------------------------------------------------------------------
// DebuggerCompose
// ---------------------------------------------------------------------------
// DebuggerCompose returns a service config map for the langgraph-debugger
// container, or nil if port is 0 (no debugger requested).
func DebuggerCompose(port int, baseURL string) *OrderedMap {
if port == 0 {
return nil
}
dependsOn := NewOrderedMap()
postgresCondition := NewOrderedMap()
postgresCondition.Set("condition", "service_healthy")
dependsOn.Set("langgraph-postgres", postgresCondition)
service := NewOrderedMap()
service.Set("image", "langchain/langgraph-debugger")
service.Set("restart", "on-failure")
service.Set("depends_on", dependsOn)
service.Set("ports", []any{fmt.Sprintf(`"%d:3968"`, port)})
if baseURL != "" {
env := NewOrderedMap()
env.Set("VITE_STUDIO_LOCAL_GRAPH_URL", baseURL)
service.Set("environment", env)
}
result := NewOrderedMap()
result.Set("langgraph-debugger", service)
return result
}
// ---------------------------------------------------------------------------
// DictToYAML
// ---------------------------------------------------------------------------
// DictToYAML converts an OrderedMap to a YAML string. For top-level keys
// (indent < 2) it adds a blank line between entries (except the first).
func DictToYAML(d *OrderedMap, indent int) string {
var b strings.Builder
for idx, key := range d.Keys {
// Add blank line between top-level entries (except first)
if idx >= 1 && indent < 2 {
b.WriteString("\n")
}
space := strings.Repeat(" ", indent)
value := d.Values[key]
switch v := value.(type) {
case *OrderedMap:
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
b.WriteString(DictToYAML(v, indent+1))
case []any:
b.WriteString(fmt.Sprintf("%s%s:\n", space, key))
for _, item := range v {
b.WriteString(fmt.Sprintf("%s - %v\n", space, item))
}
default:
b.WriteString(fmt.Sprintf("%s%s: %v\n", space, key, value))
}
}
return b.String()
}
// ---------------------------------------------------------------------------
// ComposeAsDict
// ---------------------------------------------------------------------------
// ComposeAsDict builds the docker-compose configuration as an OrderedMap.
func ComposeAsDict(caps *DockerCapabilities, opts ComposeOpts) *OrderedMap {
postgresURI := opts.PostgresURI
includeDB := false
if postgresURI == "" {
includeDB = true
postgresURI = DefaultPostgresURI
}
services := NewOrderedMap()
// --- Redis service ---
redisHealthcheck := NewOrderedMap()
redisHealthcheck.Set("test", "redis-cli ping")
redisHealthcheck.Set("interval", "5s")
redisHealthcheck.Set("timeout", "1s")
redisHealthcheck.Set("retries", 5)
redisService := NewOrderedMap()
redisService.Set("image", "redis:6")
redisService.Set("healthcheck", redisHealthcheck)
services.Set("langgraph-redis", redisService)
// --- Postgres service (if no custom URI) ---
if includeDB {
pgEnv := NewOrderedMap()
pgEnv.Set("POSTGRES_DB", "postgres")
pgEnv.Set("POSTGRES_USER", "postgres")
pgEnv.Set("POSTGRES_PASSWORD", "postgres")
pgHealthcheck := NewOrderedMap()
pgHealthcheck.Set("test", "pg_isready -U postgres")
pgHealthcheck.Set("start_period", "10s")
pgHealthcheck.Set("timeout", "1s")
pgHealthcheck.Set("retries", 5)
if caps.HealthcheckStartInterval {
pgHealthcheck.Set("interval", "60s")
pgHealthcheck.Set("start_interval", "1s")
} else {
pgHealthcheck.Set("interval", "5s")
}
pgService := NewOrderedMap()
pgService.Set("image", "pgvector/pgvector:pg16")
pgService.Set("ports", []any{`"5433:5432"`})
pgService.Set("environment", pgEnv)
pgService.Set("command", []any{"postgres", "-c", "shared_preload_libraries=vector"})
pgService.Set("volumes", []any{"langgraph-data:/var/lib/postgresql/data"})
pgService.Set("healthcheck", pgHealthcheck)
services.Set("langgraph-postgres", pgService)
}
// --- Debugger service (optional) ---
if opts.DebuggerPort != 0 {
debuggerMap := DebuggerCompose(opts.DebuggerPort, opts.DebuggerBaseURL)
if debuggerMap != nil {
debuggerService, _ := debuggerMap.Get("langgraph-debugger")
services.Set("langgraph-debugger", debuggerService)
}
}
// --- langgraph-api service ---
apiEnv := NewOrderedMap()
apiEnv.Set("REDIS_URI", "redis://langgraph-redis:6379")
apiEnv.Set("POSTGRES_URI", postgresURI)
if opts.EngineRuntimeMode == "distributed" {
apiEnv.Set("N_JOBS_PER_WORKER", `"0"`)
}
apiDependsOn := NewOrderedMap()
redisCondition := NewOrderedMap()
redisCondition.Set("condition", "service_healthy")
apiDependsOn.Set("langgraph-redis", redisCondition)
if includeDB {
pgCondition := NewOrderedMap()
pgCondition.Set("condition", "service_healthy")
apiDependsOn.Set("langgraph-postgres", pgCondition)
}
apiService := NewOrderedMap()
apiService.Set("ports", []any{fmt.Sprintf(`"%d:8000"`, opts.Port)})
apiService.Set("depends_on", apiDependsOn)
apiService.Set("environment", apiEnv)
if opts.Image != "" {
apiService.Set("image", opts.Image)
}
if caps.HealthcheckStartInterval {
apiHealthcheck := NewOrderedMap()
apiHealthcheck.Set("test", "python /api/healthcheck.py")
apiHealthcheck.Set("interval", "60s")
apiHealthcheck.Set("start_interval", "1s")
apiHealthcheck.Set("start_period", "10s")
apiService.Set("healthcheck", apiHealthcheck)
}
services.Set("langgraph-api", apiService)
// --- Build final compose dict ---
composeDict := NewOrderedMap()
if includeDB {
volumes := NewOrderedMap()
volumeDriver := NewOrderedMap()
volumeDriver.Set("driver", "local")
volumes.Set("langgraph-data", volumeDriver)
composeDict.Set("volumes", volumes)
}
composeDict.Set("services", services)
return composeDict
}
// ---------------------------------------------------------------------------
// Compose
// ---------------------------------------------------------------------------
// Compose generates a docker-compose YAML string from the given capabilities
// and options.
func Compose(caps *DockerCapabilities, opts ComposeOpts) string {
d := ComposeAsDict(caps, opts)
return DictToYAML(d, 0)
}
// ---------------------------------------------------------------------------
// BuildDockerImage
// ---------------------------------------------------------------------------
// BuildDockerImage builds a Docker image from a LangGraph configuration.
// It shells out to docker build (or a custom docker command) with the
// generated Dockerfile piped via stdin.
func BuildDockerImage(opts BuildImageOpts) error {
dockerCmd := opts.DockerCommand
if len(dockerCmd) == 0 {
dockerCmd = []string{"docker", "build"}
}
// Pull the base image first if requested.
if opts.Pull {
pullCmd := exec.Command("docker", "pull", opts.Tag)
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
if err := pullCmd.Run(); err != nil {
return fmt.Errorf("failed to pull image %s: %w", opts.Tag, err)
}
}
// Build the docker build arguments.
args := []string{
"-f", "-", // read Dockerfile from stdin
"-t", opts.Tag,
}
// Determine build context.
buildContext := "."
if opts.ConfigPath != "" {
// Use the parent directory of the config file by default.
idx := strings.LastIndex(opts.ConfigPath, "/")
if idx >= 0 {
buildContext = opts.ConfigPath[:idx]
}
}
// Generate the Dockerfile using the real config.ConfigToDocker.
dockerfile, additionalContexts, err := config.ConfigToDocker(opts.ConfigPath, opts.ConfigJSON, config.DockerOpts{
BaseImage: opts.BaseImage,
APIVersion: opts.APIVersion,
InstallCommand: opts.InstallCommand,
BuildCommand: opts.BuildCommand,
})
if err != nil {
return fmt.Errorf("generating Dockerfile: %w", err)
}
// Add additional build contexts (for dependencies outside the main context).
for name, path := range additionalContexts {
args = append(args, "--build-context", fmt.Sprintf("%s=%s", name, path))
}
// Assemble the full command.
fullArgs := append(dockerCmd[1:], args...)
fullArgs = append(fullArgs, opts.ExtraFlags...)
fullArgs = append(fullArgs, opts.Passthrough...)
fullArgs = append(fullArgs, buildContext)
cmd := exec.Command(dockerCmd[0], fullArgs...)
cmd.Stdin = strings.NewReader(dockerfile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
-290
View File
@@ -1,290 +0,0 @@
package docker
import (
"fmt"
"strings"
"testing"
)
func cleanEmptyLines(s string) string {
lines := strings.Split(s, "\n")
var result []string
for _, line := range lines {
if strings.TrimSpace(line) != "" {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
var defaultCaps = &DockerCapabilities{
VersionDocker: Version{Major: 26, Minor: 1, Patch: 1},
VersionCompose: Version{Major: 2, Minor: 27, Patch: 0},
HealthcheckStartInterval: false,
}
func TestComposeCustomDBNoDebugger(t *testing.T) {
port := 8123
actual := Compose(defaultCaps, ComposeOpts{
Port: port,
PostgresURI: "custom_postgres_uri",
})
expected := fmt.Sprintf(`services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: custom_postgres_uri`, port)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeCustomDBWithHealthcheck(t *testing.T) {
port := 8123
capsHC := &DockerCapabilities{
VersionDocker: Version{Major: 26, Minor: 1, Patch: 1},
VersionCompose: Version{Major: 2, Minor: 27, Patch: 0},
HealthcheckStartInterval: true,
}
actual := Compose(capsHC, ComposeOpts{
Port: port,
PostgresURI: "custom_postgres_uri",
})
expected := fmt.Sprintf(`services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: custom_postgres_uri
healthcheck:
test: python /api/healthcheck.py
interval: 60s
start_interval: 1s
start_period: 10s`, port)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeDefaultDB(t *testing.T) {
port := 8123
actual := Compose(defaultCaps, ComposeOpts{Port: port})
expected := fmt.Sprintf(`volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: %s`, port, DefaultPostgresURI)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeDistributedMode(t *testing.T) {
port := 8123
actual := Compose(defaultCaps, ComposeOpts{
Port: port,
PostgresURI: "custom_postgres_uri",
EngineRuntimeMode: "distributed",
})
expected := fmt.Sprintf(`services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: custom_postgres_uri
N_JOBS_PER_WORKER: "0"`, port)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestComposeCombinedModeNoNJobs(t *testing.T) {
actual := Compose(defaultCaps, ComposeOpts{
Port: 8123,
EngineRuntimeMode: "combined_queue_worker",
})
if strings.Contains(actual, "N_JOBS_PER_WORKER") {
t.Error("combined mode should not contain N_JOBS_PER_WORKER")
}
}
func TestComposeDebuggerDefaultDB(t *testing.T) {
port := 8123
debuggerPort := 8001
actual := Compose(defaultCaps, ComposeOpts{
Port: port,
DebuggerPort: debuggerPort,
})
expected := fmt.Sprintf(`volumes:
langgraph-data:
driver: local
services:
langgraph-redis:
image: redis:6
healthcheck:
test: redis-cli ping
interval: 5s
timeout: 1s
retries: 5
langgraph-postgres:
image: pgvector/pgvector:pg16
ports:
- "5433:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command:
- postgres
- -c
- shared_preload_libraries=vector
volumes:
- langgraph-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 5s
langgraph-debugger:
image: langchain/langgraph-debugger
restart: on-failure
depends_on:
langgraph-postgres:
condition: service_healthy
ports:
- "%d:3968"
langgraph-api:
ports:
- "%d:8000"
depends_on:
langgraph-redis:
condition: service_healthy
langgraph-postgres:
condition: service_healthy
environment:
REDIS_URI: redis://langgraph-redis:6379
POSTGRES_URI: %s`, debuggerPort, port, DefaultPostgresURI)
if cleanEmptyLines(actual) != expected {
t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual))
}
}
func TestParseVersion(t *testing.T) {
tests := []struct {
input string
expected Version
}{
{"1.2.3", Version{1, 2, 3}},
{"v1.2.3", Version{1, 2, 3}},
{"1.2.3-alpha", Version{1, 2, 3}},
{"1.2.3+1", Version{1, 2, 3}},
{"1.2.3-alpha+build", Version{1, 2, 3}},
{"1.2", Version{1, 2, 0}},
{"1", Version{1, 0, 0}},
{"v28.1.1+1", Version{28, 1, 1}},
{"2.0.0-beta.1+exp.sha.5114f85", Version{2, 0, 0}},
{"v3.4.5-rc1+build.123", Version{3, 4, 5}},
}
for _, tc := range tests {
result := ParseVersion(tc.input)
if result != tc.expected {
t.Errorf("ParseVersion(%q) = %v, want %v", tc.input, result, tc.expected)
}
}
}
func TestVersionGreaterOrEqual(t *testing.T) {
tests := []struct {
v, other Version
want bool
}{
{Version{25, 0, 0}, Version{25, 0, 0}, true},
{Version{26, 1, 1}, Version{25, 0, 0}, true},
{Version{24, 9, 9}, Version{25, 0, 0}, false},
}
for _, tc := range tests {
got := tc.v.GreaterOrEqual(tc.other)
if got != tc.want {
t.Errorf("%v.GreaterOrEqual(%v) = %v, want %v", tc.v, tc.other, got, tc.want)
}
}
}
-123
View File
@@ -1,123 +0,0 @@
// Package lgexec provides subprocess execution helpers for the LangGraph CLI.
//
// The package name is lgexec (rather than exec) to avoid shadowing the
// standard library os/exec package.
package lgexec
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// RunOpts configures how a subprocess is executed.
type RunOpts struct {
Stdin string // input to pass via stdin
Verbose bool // pipe stdout/stderr to os.Stdout/os.Stderr
Dir string // working directory
Env []string // environment variables (KEY=VALUE)
}
// Run executes the named program with the given arguments.
//
// When Verbose is true stdout and stderr are forwarded to the process's
// os.Stdout / os.Stderr. Otherwise output is silently discarded.
// A non-zero exit code is returned as an *ExitError.
func Run(name string, args []string, opts RunOpts) error {
cmd := exec.Command(name, args...)
if opts.Dir != "" {
cmd.Dir = opts.Dir
}
if len(opts.Env) > 0 {
cmd.Env = append(os.Environ(), opts.Env...)
}
if opts.Stdin != "" {
cmd.Stdin = strings.NewReader(opts.Stdin)
}
if opts.Verbose {
if opts.Stdin != "" {
cmdStr := fmt.Sprintf("+ %s %s", name, strings.Join(args, " "))
fmt.Printf("%s <\n%s\n", cmdStr, strings.Join(
nonEmptyLines(opts.Stdin), "\n"))
} else {
fmt.Printf("+ %s %s\n", name, strings.Join(args, " "))
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
}
return cmd.Run()
}
// RunCollect executes the named program and collects stdout and stderr.
// Both are returned as strings. A non-zero exit code results in a non-nil error.
func RunCollect(name string, args []string) (stdout, stderr string, err error) {
cmd := exec.Command(name, args...)
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
// RunWithCallback executes the named program and invokes onStdout for each
// line of stdout output. If onStdout returns true the callback is no longer
// called and remaining stdout is forwarded directly to os.Stdout (matching
// the Python CLI's monitor_stream behaviour).
// Stderr is always forwarded to os.Stderr.
func RunWithCallback(name string, args []string, onStdout func(string) bool) error {
cmd := exec.Command(name, args...)
cmd.Stderr = os.Stderr
pipe, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("cannot create stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("cannot start command: %w", err)
}
scanner := bufio.NewScanner(pipe)
stopped := false
for scanner.Scan() {
line := scanner.Text()
if stopped {
// After callback signalled stop, forward remaining output.
fmt.Fprintln(os.Stdout, line)
continue
}
if onStdout(line) {
stopped = true
}
}
if scanErr := scanner.Err(); scanErr != nil {
// Drain but ignore read errors on stdout — the exit code matters.
_ = scanErr
}
return cmd.Wait()
}
// nonEmptyLines splits s on newlines and returns lines that are not empty.
func nonEmptyLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if line != "" {
out = append(out, line)
}
}
return out
}
File diff suppressed because it is too large Load Diff
-287
View File
@@ -1,287 +0,0 @@
package root
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run(nil, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if stderr.Len() != 0 {
t.Fatalf("expected no stderr output, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "validate") {
t.Fatalf("expected help text to contain 'validate', got %q", stdout.String())
}
}
func TestRunVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"version"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if stderr.Len() != 0 {
t.Fatalf("expected no stderr output, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "langgraph") {
t.Fatalf("unexpected stdout: %q", stdout.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"nonexistent-cmd"}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "is not a langgraph command") {
t.Fatalf("unexpected stderr: %q", stderr.String())
}
}
func writeTempConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "langgraph.json")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
return path
}
func TestRunValidateWithValidConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is valid") {
t.Fatalf("expected stdout to contain 'is valid', got %q", stdout.String())
}
if !strings.Contains(stdout.String(), "1 graph") {
t.Fatalf("expected stdout to contain '1 graph', got %q", stdout.String())
}
}
func TestRunValidateWithInvalidConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"graphs": {}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "No graphs found") {
t.Fatalf("expected stderr to contain 'No graphs found', got %q", stderr.String())
}
}
func TestRunValidateWithInvalidJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{invalid json`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "Invalid JSON") {
t.Fatalf("expected stderr to contain 'Invalid JSON', got %q", stderr.String())
}
}
func TestRunValidateWithNonObjectJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `[]`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "top-level JSON value must be an object") {
t.Fatalf("expected stderr to mention object-shaped config, got %q", stderr.String())
}
}
func TestRunValidateDefaultConfigMissing(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
// Use a path that definitely does not exist.
nonexistent := filepath.Join(t.TempDir(), "langgraph.json")
exitCode := Run([]string{"validate", "-c", nonexistent}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "does not exist") {
t.Fatalf("expected stderr to contain 'does not exist', got %q", stderr.String())
}
}
func TestRunValidateWithUnknownKeys(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}, "grpahs": {}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
out := stdout.String()
if !strings.Contains(strings.ToLower(out), "warning") {
t.Fatalf("expected stdout to contain 'warning', got %q", out)
}
if !strings.Contains(strings.ToLower(out), "did you mean") {
t.Fatalf("expected stdout to contain 'did you mean', got %q", out)
}
}
func TestRunValidateHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"validate", "--help"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(stdout.String(), "Validate the LangGraph configuration file") {
t.Fatalf("expected stdout to contain validate help text, got %q", stdout.String())
}
}
func TestRunValidateMultipleGraphs(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./a.py:g", "bot": "./b.py:g"}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "2 graphs found") {
t.Fatalf("expected stdout to contain '2 graphs found', got %q", stdout.String())
}
}
func TestRunValidateWithWarningsAndErrors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
path := writeTempConfig(t, `{"graphs": {}, "grpahs": {}}`)
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
errOut := stderr.String()
if !strings.Contains(errOut, "No graphs found") {
t.Fatalf("expected stderr to contain 'No graphs found', got %q", errOut)
}
if !strings.Contains(strings.ToLower(errOut), "warning") {
t.Fatalf("expected stderr to contain 'warning', got %q", errOut)
}
}
func TestRunValidateWithInvalidPackageJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
dir := t.TempDir()
configPath := filepath.Join(dir, "langgraph.json")
packagePath := filepath.Join(dir, "package.json")
if err := os.WriteFile(
configPath,
[]byte(`{"node_version":"20","graphs":{"agent":"./agent.js:graph"}}`),
0644,
); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if err := os.WriteFile(packagePath, []byte(`{invalid json`), 0644); err != nil {
t.Fatalf("failed to write package.json: %v", err)
}
exitCode := Run([]string{"validate", "-c", configPath}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if !strings.Contains(stderr.String(), "Invalid package.json found") {
t.Fatalf("expected stderr to mention invalid package.json, got %q", stderr.String())
}
}
func TestRunDeployDelegatesToPythonCLI(t *testing.T) {
t.Setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python")
originalRunPythonSubprocess := runPythonSubprocess
t.Cleanup(func() {
runPythonSubprocess = originalRunPythonSubprocess
})
var gotPython string
var gotArgs []string
runPythonSubprocess = func(
pythonExe string,
args []string,
stdout io.Writer,
stderr io.Writer,
) error {
gotPython = pythonExe
gotArgs = append([]string(nil), args...)
return nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"deploy", "--remote", "--install-command", "make deps"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
}
if gotPython != "/custom/python" {
t.Fatalf("expected delegated python to be /custom/python, got %q", gotPython)
}
expected := []string{"-m", "langgraph_cli.cli", "deploy", "--remote", "--install-command", "make deps"}
if strings.Join(gotArgs, "\x00") != strings.Join(expected, "\x00") {
t.Fatalf("unexpected delegated args: got %q want %q", gotArgs, expected)
}
}
-258
View File
@@ -1,258 +0,0 @@
// Package templates provides template definitions and project scaffolding
// for the LangGraph CLI `new` command.
package templates
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
// Template describes a project template with language-specific download URLs.
type Template struct {
Name string
Description string
Languages map[string]string // lang -> download URL
}
// Templates is the ordered list of available project templates.
var Templates = []Template{
{
Name: "Deep Agent",
Description: "An opinionated deployment template for a Deep Agent.",
Languages: map[string]string{
"python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
"js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
},
},
{
Name: "Agent",
Description: "A simple agent that can be flexibly extended to many tools.",
Languages: map[string]string{
"python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
},
},
{
Name: "New LangGraph Project",
Description: "A simple, minimal chatbot with memory.",
Languages: map[string]string{
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
},
},
}
// templateIDEntry maps a template ID to its download URL, template name, and language.
type templateIDEntry struct {
URL string
Name string
Language string
}
// templateIDMap is built once at init time from the Templates slice.
var templateIDMap map[string]templateIDEntry
func init() {
templateIDMap = make(map[string]templateIDEntry)
for _, t := range Templates {
for lang, url := range t.Languages {
if lang != "python" && lang != "js" {
continue
}
id := toTemplateID(t.Name, lang)
templateIDMap[id] = templateIDEntry{
URL: url,
Name: t.Name,
Language: lang,
}
}
}
}
// toTemplateID converts a template name and language into a slug like "deep-agent-python".
func toTemplateID(name, lang string) string {
return strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "-" + lang
}
// ListTemplateIDs returns a sorted list of all available template IDs.
func ListTemplateIDs() []string {
ids := make([]string, 0, len(templateIDMap))
for id := range templateIDMap {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// TemplateHelp returns a formatted help string listing available templates.
func TemplateHelp() string {
var b strings.Builder
b.WriteString("The name of the template to use. Available options:\n")
for _, id := range ListTemplateIDs() {
entry := templateIDMap[id]
// Find the description from the Templates slice.
var desc string
for _, t := range Templates {
if t.Name == entry.Name {
desc = t.Description
break
}
}
fmt.Fprintf(&b, " %s: %s\n", id, desc)
}
return b.String()
}
// CreateNew creates a new LangGraph project at path using the given templateID.
//
// If templateID is empty an error listing available templates is returned (the
// Go CLI is non-interactive, so we cannot prompt). If path is empty an error
// is returned.
func CreateNew(path, templateID string) error {
if path == "" {
return fmt.Errorf("path is required: specify the directory for the new project")
}
// Resolve to absolute path.
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("cannot resolve path: %w", err)
}
path = absPath
// Check if path exists and is not empty.
entries, err := os.ReadDir(path)
if err == nil && len(entries) > 0 {
return fmt.Errorf(
"the specified directory already exists and is not empty: %s. "+
"Aborting to prevent overwriting files", path)
}
if templateID == "" {
return fmt.Errorf(
"template is required. Use one of the following template IDs:\n%s",
TemplateHelp())
}
entry, ok := templateIDMap[templateID]
if !ok {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("template %q not found.\n", templateID))
sb.WriteString("Please select from the available options:\n")
for _, id := range ListTemplateIDs() {
e := templateIDMap[id]
var desc string
for _, t := range Templates {
if t.Name == e.Name {
desc = t.Description
break
}
}
fmt.Fprintf(&sb, " - %s: %s\n", id, desc)
}
return fmt.Errorf("%s", sb.String())
}
if err := DownloadAndExtract(entry.URL, path); err != nil {
return fmt.Errorf("failed to download template: %w", err)
}
return nil
}
// DownloadAndExtract downloads a ZIP archive from url and extracts it to
// destPath, stripping the top-level wrapper directory that GitHub includes
// in repository archives.
func DownloadAndExtract(url, destPath string) error {
// Ensure destination directory exists.
if err := os.MkdirAll(destPath, 0o755); err != nil {
return fmt.Errorf("cannot create destination directory: %w", err)
}
// Download to a temporary file.
tmpFile, err := os.CreateTemp("", "langgraph-template-*.zip")
if err != nil {
return fmt.Errorf("cannot create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
resp, err := http.Get(url) //nolint:gosec
if err != nil {
tmpFile.Close()
return fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tmpFile.Close()
return fmt.Errorf("HTTP %d: failed to download %s", resp.StatusCode, url)
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write ZIP data: %w", err)
}
tmpFile.Close()
// Open the ZIP archive.
zr, err := zip.OpenReader(tmpPath)
if err != nil {
return fmt.Errorf("failed to open ZIP archive: %w", err)
}
defer zr.Close()
for _, f := range zr.File {
// Strip the first path component (GitHub's wrapper directory).
parts := strings.SplitN(f.Name, "/", 2)
if len(parts) < 2 || parts[1] == "" {
continue // skip the wrapper directory entry itself
}
relPath := parts[1]
outPath := filepath.Join(destPath, relPath)
// Ensure the output path is within destPath (zip-slip protection).
if !strings.HasPrefix(filepath.Clean(outPath), filepath.Clean(destPath)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(outPath, f.Mode()); err != nil {
return fmt.Errorf("cannot create directory %s: %w", outPath, err)
}
continue
}
// Create parent directories.
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return fmt.Errorf("cannot create parent directory: %w", err)
}
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
if err != nil {
return fmt.Errorf("cannot create file %s: %w", outPath, err)
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return fmt.Errorf("cannot read ZIP entry %s: %w", f.Name, err)
}
if _, err := io.Copy(outFile, rc); err != nil {
rc.Close()
outFile.Close()
return fmt.Errorf("failed writing %s: %w", outPath, err)
}
rc.Close()
outFile.Close()
}
return nil
}
-7
View File
@@ -1,7 +0,0 @@
package version
var (
Version = "dev"
Commit = "unknown"
Date = "unknown"
)
+8 -29
View File
@@ -1086,11 +1086,6 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
"@types/uuid@^10.0.0":
version "10.0.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
"@types/yargs-parser@*":
version "21.0.3"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
@@ -1782,13 +1777,6 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
console-table-printer@^2.12.1:
version "2.15.0"
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.15.0.tgz#5c808204640b8f024d545bde8aabe5d344dfadc1"
integrity sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==
dependencies:
simple-wcswidth "^1.1.2"
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@@ -3688,16 +3676,12 @@ keyv@^4.5.4:
json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0":
version "0.5.4"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
dependencies:
"@types/uuid" "^10.0.0"
chalk "^4.1.2"
console-table-printer "^2.12.1"
p-queue "^6.6.2"
semver "^7.6.3"
uuid "^10.0.0"
p-queue "6.6.2"
uuid "10.0.0"
leven@^3.1.0:
version "3.1.0"
@@ -4007,7 +3991,7 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"
p-queue@^6.6.2:
p-queue@6.6.2, p-queue@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
@@ -4303,7 +4287,7 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
@@ -4411,11 +4395,6 @@ signal-exit@^4.0.1:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
simple-wcswidth@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b"
integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -4870,7 +4849,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
uuid@^10.0.0:
uuid@10.0.0, uuid@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+7 -72
View File
@@ -217,11 +217,6 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/uuid@^10.0.0":
version "10.0.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
"@typescript-eslint/eslint-plugin@^8.58.0":
version "8.58.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa"
@@ -343,13 +338,6 @@ ajv@^6.14.0:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
dependencies:
color-convert "^2.0.1"
ansi-styles@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
@@ -508,38 +496,11 @@ camelcase@6:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
chalk@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
dependencies:
color-name "~1.1.4"
color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
console-table-printer@^2.12.1:
version "2.14.6"
resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.14.6.tgz#edfe0bf311fa2701922ed509443145ab51e06436"
integrity sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==
dependencies:
simple-wcswidth "^1.0.1"
cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
@@ -1059,11 +1020,6 @@ has-bigints@^1.0.2:
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==
has-flag@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
@@ -1372,16 +1328,12 @@ keyv@^4.5.4:
json-buffer "3.0.1"
"langsmith@>=0.5.0 <1.0.0":
version "0.5.4"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.4.tgz#f75b82b08e30db72a7d1d595b341e9666bd525e5"
integrity sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==
version "0.5.20"
resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.20.tgz#4021847d2ccd5a86c5eb96060f9bb5f19f80eca5"
integrity sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==
dependencies:
"@types/uuid" "^10.0.0"
chalk "^4.1.2"
console-table-printer "^2.12.1"
p-queue "^6.6.2"
semver "^7.6.3"
uuid "^10.0.0"
p-queue "6.6.2"
uuid "10.0.0"
levn@^0.4.1:
version "0.4.1"
@@ -1528,7 +1480,7 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"
p-queue@^6.6.2:
p-queue@6.6.2, p-queue@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
@@ -1690,11 +1642,6 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.6.3:
version "7.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
@@ -1783,11 +1730,6 @@ side-channel@^1.1.0:
side-channel-map "^1.0.1"
side-channel-weakmap "^1.0.2"
simple-wcswidth@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b"
integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==
stop-iteration-iterator@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad"
@@ -1838,13 +1780,6 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
dependencies:
has-flag "^4.0.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
@@ -1966,7 +1901,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
uuid@^10.0.0:
uuid@10.0.0, uuid@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.21"
__version__ = "0.4.23"
+2 -2
View File
@@ -1,4 +1,4 @@
from .entrypoint import main
from .cli import cli
if __name__ == "__main__":
main()
cli()
+10 -3
View File
@@ -26,8 +26,15 @@ class LogData(TypedDict):
params: dict[str, Any]
def get_anonymized_params(kwargs: dict[str, Any]) -> dict[str, bool]:
params = {}
def get_anonymized_params(
kwargs: dict[str, Any], *, cli_command: str
) -> dict[str, bool | str]:
params: dict[str, bool | str] = {}
if cli_command == "deploy" and (
analytics_source := os.getenv("LANGGRAPH_CLI_ANALYTICS_SOURCE")
):
params["source"] = analytics_source
# anonymize params with values
if config := kwargs.get("config"):
@@ -88,7 +95,7 @@ def log_command(func):
"python_version": platform.python_version(),
"cli_version": __version__,
"cli_command": func.__name__,
"params": get_anonymized_params(kwargs),
"params": get_anonymized_params(kwargs, cli_command=func.__name__),
}
background_thread = threading.Thread(target=log_data, args=(data,))
-74
View File
@@ -1,74 +0,0 @@
"""User-facing entrypoint for the LangGraph CLI."""
from __future__ import annotations
import os
import pathlib
import sys
from collections.abc import Sequence
import click
from .cli import cli
_GO_CLI_FLAG = "LANGGRAPH_USE_GO_CLI"
_GO_CLI_PATH_ENV = "LANGGRAPH_GO_CLI_PATH"
_CALLING_PYTHON_ENV = "LANGGRAPH_CALLING_PYTHON"
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
def _legacy_cli(argv: Sequence[str] | None = None) -> None:
cli.main(args=list(argv) if argv is not None else None, prog_name="langgraph")
def _should_use_go_cli() -> bool:
value = os.environ.get(_GO_CLI_FLAG, "")
return value.strip().lower() in _TRUE_VALUES
def _bundled_go_cli_path() -> pathlib.Path:
binary_name = "langgraph.exe" if os.name == "nt" else "langgraph"
return pathlib.Path(__file__).resolve().parent / "bin" / binary_name
def _resolve_go_cli_path() -> pathlib.Path | None:
override = os.environ.get(_GO_CLI_PATH_ENV)
if override:
path = pathlib.Path(override).expanduser()
return path.resolve()
bundled = _bundled_go_cli_path()
if bundled.is_file():
return bundled
return None
def _exec_go_cli(argv: Sequence[str]) -> None:
path = _resolve_go_cli_path()
if path is None:
raise click.ClickException(
"Go CLI requested via LANGGRAPH_USE_GO_CLI, but no langgraph binary was "
"found. Set LANGGRAPH_GO_CLI_PATH or install a wheel that bundles the "
"binary."
)
if not path.is_file():
raise click.ClickException(
f"LANGGRAPH_GO_CLI_PATH points to a missing file: {path}"
)
env = os.environ.copy()
env.setdefault(_CALLING_PYTHON_ENV, sys.executable)
os.execvpe(str(path), [str(path), *argv], env)
def main(argv: Sequence[str] | None = None) -> None:
args = list(sys.argv[1:] if argv is None else argv)
try:
if _should_use_go_cli():
_exec_go_cli(args)
_legacy_cli(args)
except click.ClickException as exc:
exc.show()
raise SystemExit(exc.exit_code) from exc
+2 -5
View File
@@ -23,7 +23,7 @@ dependencies = [
path = "langgraph_cli/__init__.py"
[project.optional-dependencies]
inmem = [
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
"langgraph-api>=0.5.35,<0.9.0 ; python_version >= '3.11'",
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
]
@@ -34,7 +34,7 @@ Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
[project.scripts]
langgraph = "langgraph_cli.entrypoint:main"
langgraph = "langgraph_cli.cli:cli"
[dependency-groups]
test = [
@@ -61,9 +61,6 @@ default-groups = ['dev']
[tool.hatch.build.targets.wheel]
include = ["langgraph_cli"]
[tool.hatch.build.targets.wheel.hooks.custom]
path = "hatch_build.py"
[tool.pytest.ini_options]
addopts = "--strict-markers --strict-config --durations=5 -vv"
asyncio_mode = "auto"
@@ -1,116 +0,0 @@
import pathlib
import sys
import pytest
from langgraph_cli import entrypoint
def test_main_uses_legacy_cli_when_go_flag_disabled(monkeypatch):
captured = {}
def fake_legacy(argv):
captured["argv"] = list(argv)
monkeypatch.delenv("LANGGRAPH_USE_GO_CLI", raising=False)
monkeypatch.setattr(entrypoint, "_legacy_cli", fake_legacy)
entrypoint.main(["build", "-t", "demo"])
assert captured == {"argv": ["build", "-t", "demo"]}
def test_main_execs_go_cli_when_flag_enabled(monkeypatch, tmp_path):
binary_path = tmp_path / "langgraph"
binary_path.write_text("")
captured = {}
def fake_execvpe(file, args, env):
captured["file"] = file
captured["args"] = args
captured["env"] = env.copy()
raise SystemExit(0)
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path))
monkeypatch.delenv("LANGGRAPH_CALLING_PYTHON", raising=False)
monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe)
with pytest.raises(SystemExit, match="0"):
entrypoint.main(["dev", "--port", "8000"])
assert captured["file"] == str(binary_path)
assert captured["args"] == [str(binary_path), "dev", "--port", "8000"]
assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == sys.executable
def test_main_preserves_existing_calling_python(monkeypatch, tmp_path):
binary_path = tmp_path / "langgraph"
binary_path.write_text("")
captured = {}
def fake_execvpe(file, args, env):
captured["env"] = env.copy()
raise SystemExit(0)
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "true")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path))
monkeypatch.setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python")
monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe)
with pytest.raises(SystemExit, match="0"):
entrypoint.main(["dev"])
assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == "/custom/python"
def test_main_errors_when_go_cli_requested_but_binary_missing(
monkeypatch, capsys, tmp_path
):
missing_path = tmp_path / "missing-langgraph"
monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(missing_path))
with pytest.raises(SystemExit, match="1"):
entrypoint.main(["build"])
err = capsys.readouterr().err
assert "LANGGRAPH_GO_CLI_PATH points to a missing file" in err
def test_resolve_go_cli_path_prefers_override(monkeypatch, tmp_path):
override = tmp_path / "custom-langgraph"
override.write_text("")
bundled = tmp_path / "bin" / "langgraph"
bundled.parent.mkdir()
bundled.write_text("")
monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(override))
monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled)
assert entrypoint._resolve_go_cli_path() == override.resolve()
def test_resolve_go_cli_path_uses_bundled_binary(monkeypatch, tmp_path):
bundled = tmp_path / "bin" / "langgraph"
bundled.parent.mkdir()
bundled.write_text("")
monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False)
monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled)
assert entrypoint._resolve_go_cli_path() == bundled
def test_resolve_go_cli_path_returns_none_when_nothing_available(monkeypatch):
monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False)
monkeypatch.setattr(
entrypoint,
"_bundled_go_cli_path",
lambda: pathlib.Path("/definitely/not/present/langgraph"),
)
assert entrypoint._resolve_go_cli_path() is None
@@ -1,67 +0,0 @@
import os
import shutil
import subprocess
import pytest
@pytest.fixture
def go_cli_env():
if os.environ.get("LANGGRAPH_USE_GO_CLI") != "1":
pytest.skip("Go CLI smoke tests only run when LANGGRAPH_USE_GO_CLI=1")
langgraph = shutil.which("langgraph")
assert langgraph is not None, "langgraph executable is not installed"
env = os.environ.copy()
env["LANGGRAPH_USE_GO_CLI"] = "1"
return langgraph, env
def test_go_cli_help(go_cli_env):
langgraph, env = go_cli_env
result = subprocess.run(
[langgraph, "--help"],
capture_output=True,
text=True,
env=env,
check=False,
)
assert result.returncode == 0, result.stderr
assert "Usage: langgraph" in result.stdout
def test_go_cli_validate(go_cli_env, tmp_path):
langgraph, env = go_cli_env
config_path = tmp_path / "langgraph.json"
config_path.write_text(
'{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}}'
)
result = subprocess.run(
[langgraph, "validate", "-c", str(config_path)],
capture_output=True,
text=True,
env=env,
check=False,
)
assert result.returncode == 0, result.stderr
assert "is valid" in result.stdout
def test_go_cli_dev_help(go_cli_env):
langgraph, env = go_cli_env
result = subprocess.run(
[langgraph, "dev", "--help"],
capture_output=True,
text=True,
env=env,
check=False,
)
assert result.returncode == 0, result.stderr
assert "Run LangGraph API server in development mode" in result.stdout
+6 -6
View File
@@ -215,7 +215,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.27"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -227,9 +227,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/5c/56d19a252bbb26247b7a7cd20821d48804d7ca03212fec709cd8db7c2516/langchain_core-1.2.27.tar.gz", hash = "sha256:c18372e4c4c1454d49bf23a2e484431e71bd39b64173a0f621f0fc283d7183a4", size = 844935, upload-time = "2026-04-07T14:56:32.364Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/c3/6e0865bc130c448270eb9511b47863a3f9145cdb519b19f6e4758fa63d6f/langchain_core-1.2.27-py3-none-any.whl", hash = "sha256:9ecd6b0393b969fe88f6b9b309367134080ab095946d79e6937dd3911aa42bd5", size = 508315, upload-time = "2026-04-07T14:56:30.93Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
@@ -290,7 +290,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.26"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -303,9 +303,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/86/6de4f6f0451a9658f26f633e0bb090552a4dafd7df3f1ae7f0d40558e67e/langsmith-0.7.26.tar.gz", hash = "sha256:a3e06f3d689ce7195717aa6b8f91082319819ec7ea9b9a62cdcd3d9dc25bfc7b", size = 1146118, upload-time = "2026-04-06T15:01:03.336Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/8e/7eb7d65ce62e98e74b9f18f193ea7ac3996d4fbd71fffcc67d0f7ba3103e/langsmith-0.7.26-py3-none-any.whl", hash = "sha256:fe5c877972cea450c1c48251c8fae0f18543c8d19dfdb9ff9a9c4263763dde4e", size = 360160, upload-time = "2026-04-06T15:01:01.516Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
+6 -6
View File
@@ -191,7 +191,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.27"
version = "1.2.28"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -203,9 +203,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/13/5c/56d19a252bbb26247b7a7cd20821d48804d7ca03212fec709cd8db7c2516/langchain_core-1.2.27.tar.gz", hash = "sha256:c18372e4c4c1454d49bf23a2e484431e71bd39b64173a0f621f0fc283d7183a4", size = 844935, upload-time = "2026-04-07T14:56:32.364Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/317a1a3ac1df33a64adb3670bf88bbe3b3d5baa274db6863a979db472897/langchain_core-1.2.28.tar.gz", hash = "sha256:271a3d8bd618f795fdeba112b0753980457fc90537c46a0c11998516a74dc2cb", size = 846119, upload-time = "2026-04-08T18:19:34.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/c3/6e0865bc130c448270eb9511b47863a3f9145cdb519b19f6e4758fa63d6f/langchain_core-1.2.27-py3-none-any.whl", hash = "sha256:9ecd6b0393b969fe88f6b9b309367134080ab095946d79e6937dd3911aa42bd5", size = 508315, upload-time = "2026-04-07T14:56:30.93Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/32f785f077c7e898da97064f113c73fbd9ad55d1e2169cf3a391b183dedb/langchain_core-1.2.28-py3-none-any.whl", hash = "sha256:80764232581eaf8057bcefa71dbf8adc1f6a28d257ebd8b95ba9b8b452e8c6ac", size = 508727, upload-time = "2026-04-08T18:19:32.823Z" },
]
[[package]]
@@ -266,7 +266,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.26"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -279,9 +279,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/86/6de4f6f0451a9658f26f633e0bb090552a4dafd7df3f1ae7f0d40558e67e/langsmith-0.7.26.tar.gz", hash = "sha256:a3e06f3d689ce7195717aa6b8f91082319819ec7ea9b9a62cdcd3d9dc25bfc7b", size = 1146118, upload-time = "2026-04-06T15:01:03.336Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/8e/7eb7d65ce62e98e74b9f18f193ea7ac3996d4fbd71fffcc67d0f7ba3103e/langsmith-0.7.26-py3-none-any.whl", hash = "sha256:fe5c877972cea450c1c48251c8fae0f18543c8d19dfdb9ff9a9c4263763dde4e", size = 360160, upload-time = "2026-04-06T15:01:01.516Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
+515 -433
View File
File diff suppressed because it is too large Load Diff
+48 -12
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections import ChainMap
from collections.abc import Mapping, Sequence
from collections.abc import Sequence
from os import getenv
from typing import Any, cast
@@ -217,14 +217,16 @@ def get_callback_manager_for_config(
callbacks.add_tags(all_tags)
if metadata := config.get("metadata"):
callbacks.add_metadata(metadata)
return callbacks
manager = callbacks
else:
# otherwise create a new manager
return CallbackManager.configure(
manager = CallbackManager.configure(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=all_tags,
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
)
return manager
def get_async_callback_manager_for_config(
@@ -255,14 +257,16 @@ def get_async_callback_manager_for_config(
callbacks.add_tags(all_tags)
if metadata := config.get("metadata"):
callbacks.add_metadata(metadata)
return callbacks
manager = callbacks
else:
# otherwise create a new manager
return AsyncCallbackManager.configure(
manager = AsyncCallbackManager.configure(
inheritable_callbacks=config.get("callbacks"),
inheritable_tags=all_tags,
inheritable_metadata=config.get("metadata"),
langsmith_inheritable_metadata=_get_tracing_metadata_defaults(config),
)
return manager
def _is_not_empty(value: Any) -> bool:
@@ -308,22 +312,54 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
for k, v in config.items():
if _is_not_empty(v) and k not in CONFIG_KEYS:
empty[CONF][k] = v
_empty_metadata = empty["metadata"]
for key, value in empty[CONF].items():
if _exclude_as_metadata(key, value, _empty_metadata):
continue
_empty_metadata[key] = value
configurable = empty.get("configurable")
metadata = empty.get("metadata")
if configurable and metadata is not None:
for key in _PROPAGATE_TO_METADATA:
if key in metadata:
continue
value = configurable.get(key)
if value:
metadata[key] = value
return empty
_OMIT = ("key", "token", "secret", "password", "auth")
def _exclude_as_metadata(key: str, value: Any, metadata: Mapping[str, Any]) -> bool:
def _exclude_as_metadata(key: str, value: Any) -> bool:
key_lower = key.casefold()
return (
key.startswith("__")
or not isinstance(value, (str, int, float, bool))
or key in metadata
or any(substr in key_lower for substr in _OMIT)
)
def _get_tracing_metadata_defaults(
config: RunnableConfig,
) -> dict[str, Any] | None:
"""Get tracer-only metadata defaults from configurable values."""
configurable = config.get("configurable")
if not configurable:
return None
metadata: dict[str, Any] = {}
for key, value in configurable.items():
if _exclude_as_metadata(key, value):
continue
metadata[key] = value
return metadata or None
_PROPAGATE_TO_METADATA = frozenset(
(
"thread_id",
"checkpoint_id",
"checkpoint_ns",
"task_id",
"run_id",
"assistant_id",
"graph_id",
)
)
+394
View File
@@ -0,0 +1,394 @@
"""Graph lifecycle callback interfaces and event payloads.
This module defines the public callback surface for observing LangGraph-specific
lifecycle transitions such as interrupt and resume.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal, TypeAlias, TypeVar
from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler, BaseCallbackManager
from langchain_core.callbacks.manager import ahandle_event, handle_event
from langchain_core.runnables import RunnableConfig
from langgraph.types import Interrupt
__all__ = (
"GraphCallbackHandler",
"GraphInterruptEvent",
"GraphLifecycleEvent",
"GraphLifecycleStatus",
"GraphResumeEvent",
"get_async_graph_callback_manager_for_config",
"get_sync_graph_callback_manager_for_config",
)
GraphLifecycleStatus: TypeAlias = Literal[
"input",
"pending",
"done",
"interrupt_before",
"interrupt_after",
"out_of_steps",
]
"""Allowed lifecycle statuses reported in graph lifecycle callback events."""
@dataclass(frozen=True)
class GraphInterruptEvent:
"""Graph lifecycle event emitted when execution pauses for interrupts."""
run_id: UUID | None
"""Run id for the current graph execution, if available."""
status: GraphLifecycleStatus
"""Loop status when the interrupt was captured."""
checkpoint_id: str
"""Checkpoint id associated with the interrupted execution."""
checkpoint_ns: tuple[str, ...]
"""Checkpoint namespace path for the current graph or subgraph."""
interrupts: tuple[Interrupt, ...]
"""Interrupt payloads that caused the graph to pause."""
@dataclass(frozen=True)
class GraphResumeEvent:
"""Graph lifecycle event emitted when execution resumes from a checkpoint."""
run_id: UUID | None
"""Run id for the current graph execution, if available."""
status: GraphLifecycleStatus
"""Loop status when the resume was captured."""
checkpoint_id: str
"""Checkpoint id the graph resumed from."""
checkpoint_ns: tuple[str, ...]
"""Checkpoint namespace path for the current graph or subgraph."""
GraphLifecycleEvent: TypeAlias = GraphInterruptEvent | GraphResumeEvent
"""Union of all public graph lifecycle callback event payloads.
Use this alias when a callback or helper can receive either interrupt or resume
lifecycle events.
"""
class GraphCallbackHandler(BaseCallbackHandler):
"""Base class for graph-level lifecycle callbacks.
Subclass this handler to observe graph lifecycle transitions that are
specific to LangGraph execution, rather than generic LangChain runnable
callbacks.
Instances can be passed through `config["callbacks"]` when invoking a
graph. Only handlers that inherit from `GraphCallbackHandler` receive these
lifecycle events.
"""
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
"""Run when graph execution pauses due to one or more interrupts.
Args:
event: Interrupt lifecycle event payload.
"""
def on_resume(self, event: GraphResumeEvent) -> Any:
"""Run when graph execution resumes from a persisted checkpoint.
Args:
event: Resume lifecycle event payload.
"""
_MISSING = object()
def _filter_graph_handlers(
handlers: list[BaseCallbackHandler],
) -> list[GraphCallbackHandler]:
return [h for h in handlers if isinstance(h, GraphCallbackHandler)]
def _init_base_manager(
manager: BaseCallbackManager,
handlers: Sequence[GraphCallbackHandler] | None,
inheritable_handlers: Sequence[GraphCallbackHandler] | None,
parent_run_id: UUID | None,
*,
tags: list[str] | None,
inheritable_tags: list[str] | None,
metadata: dict[str, Any] | None,
inheritable_metadata: dict[str, Any] | None,
run_id: UUID | None,
) -> None:
base_handlers: list[BaseCallbackHandler] = []
base_inheritable_handlers: list[BaseCallbackHandler] = []
if handlers is not None:
base_handlers.extend(handlers)
if inheritable_handlers is not None:
base_inheritable_handlers.extend(inheritable_handlers)
BaseCallbackManager.__init__(
manager,
handlers=base_handlers,
inheritable_handlers=base_inheritable_handlers,
parent_run_id=parent_run_id,
tags=tags,
inheritable_tags=inheritable_tags,
metadata=metadata,
inheritable_metadata=inheritable_metadata,
)
manager.run_id = run_id # type: ignore[attr-defined]
def _configure_graph_callbacks(
cls: type[_GraphManagerT],
callbacks: object | None,
*,
run_id: UUID | None,
) -> _GraphManagerT:
if callbacks is None:
return cls(run_id=run_id)
if isinstance(callbacks, cls):
return callbacks.copy(run_id=run_id)
if isinstance(callbacks, (_GraphCallbackManager, _AsyncGraphCallbackManager)):
# Cross-type: extract handlers into the requested cls.
return cls(
handlers=_filter_graph_handlers(callbacks.handlers),
inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
parent_run_id=callbacks.parent_run_id,
tags=callbacks.tags.copy(),
inheritable_tags=callbacks.inheritable_tags.copy(),
metadata=callbacks.metadata.copy(),
inheritable_metadata=callbacks.inheritable_metadata.copy(),
run_id=run_id,
)
if isinstance(callbacks, BaseCallbackManager):
return cls(
handlers=_filter_graph_handlers(callbacks.handlers),
inheritable_handlers=_filter_graph_handlers(callbacks.inheritable_handlers),
parent_run_id=callbacks.parent_run_id,
tags=callbacks.tags.copy(),
inheritable_tags=callbacks.inheritable_tags.copy(),
metadata=callbacks.metadata.copy(),
inheritable_metadata=callbacks.inheritable_metadata.copy(),
run_id=run_id,
)
if isinstance(callbacks, GraphCallbackHandler):
return cls((callbacks,), run_id=run_id)
if isinstance(callbacks, (str, bytes)) or not isinstance(callbacks, Sequence):
raise TypeError("callbacks must be a handler, sequence, or manager")
return cls(_filter_graph_handlers(list(callbacks)), run_id=run_id)
def _copy_graph_manager(
manager: _GraphCallbackManager | _AsyncGraphCallbackManager,
cls: type[_GraphManagerT],
run_id: UUID | None | object,
) -> _GraphManagerT:
resolved_run_id: UUID | None
if run_id is _MISSING:
resolved_run_id = manager.run_id
else:
if run_id is not None and not isinstance(run_id, UUID):
raise TypeError("run_id must be a UUID or None")
resolved_run_id = run_id
return cls(
handlers=_filter_graph_handlers(manager.handlers),
inheritable_handlers=_filter_graph_handlers(manager.inheritable_handlers),
parent_run_id=manager.parent_run_id,
tags=manager.tags.copy(),
inheritable_tags=manager.inheritable_tags.copy(),
metadata=manager.metadata.copy(),
inheritable_metadata=manager.inheritable_metadata.copy(),
run_id=resolved_run_id,
)
class _GraphCallbackManager(BaseCallbackManager):
"""Sync dispatcher for graph lifecycle events."""
run_id: UUID | None
def __init__(
self,
handlers: Sequence[GraphCallbackHandler] | None = None,
inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
parent_run_id: UUID | None = None,
*,
tags: list[str] | None = None,
inheritable_tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
inheritable_metadata: dict[str, Any] | None = None,
run_id: UUID | None = None,
) -> None:
_init_base_manager(
self,
handlers,
inheritable_handlers,
parent_run_id,
tags=tags,
inheritable_tags=inheritable_tags,
metadata=metadata,
inheritable_metadata=inheritable_metadata,
run_id=run_id,
)
def copy(
self,
*,
run_id: UUID | None | object = _MISSING,
) -> _GraphCallbackManager:
return _copy_graph_manager(self, _GraphCallbackManager, run_id)
@classmethod
def configure(
cls,
callbacks: object | None = None,
*,
run_id: UUID | None = None,
) -> _GraphCallbackManager:
return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
def on_interrupt(self, event: GraphInterruptEvent) -> None:
handle_event(
self.handlers,
"on_interrupt",
None,
event,
)
def on_resume(self, event: GraphResumeEvent) -> None:
handle_event(
self.handlers,
"on_resume",
None,
event,
)
class _AsyncGraphCallbackManager(BaseCallbackManager):
"""Async dispatcher for graph lifecycle events."""
run_id: UUID | None
@property
def is_async(self) -> bool:
"""Return whether the manager is async."""
return True
def __init__(
self,
handlers: Sequence[GraphCallbackHandler] | None = None,
inheritable_handlers: Sequence[GraphCallbackHandler] | None = None,
parent_run_id: UUID | None = None,
*,
tags: list[str] | None = None,
inheritable_tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
inheritable_metadata: dict[str, Any] | None = None,
run_id: UUID | None = None,
) -> None:
_init_base_manager(
self,
handlers,
inheritable_handlers,
parent_run_id,
tags=tags,
inheritable_tags=inheritable_tags,
metadata=metadata,
inheritable_metadata=inheritable_metadata,
run_id=run_id,
)
def copy(
self,
*,
run_id: UUID | None | object = _MISSING,
) -> _AsyncGraphCallbackManager:
return _copy_graph_manager(self, _AsyncGraphCallbackManager, run_id)
@classmethod
def configure(
cls,
callbacks: object | None = None,
*,
run_id: UUID | None = None,
) -> _AsyncGraphCallbackManager:
return _configure_graph_callbacks(cls, callbacks, run_id=run_id)
async def on_interrupt(self, event: GraphInterruptEvent) -> None:
await ahandle_event(
self.handlers,
"on_interrupt",
None,
event,
)
async def on_resume(self, event: GraphResumeEvent) -> None:
await ahandle_event(
self.handlers,
"on_resume",
None,
event,
)
_GraphManagerT = TypeVar(
"_GraphManagerT", _GraphCallbackManager, _AsyncGraphCallbackManager
)
GraphCallbacks: TypeAlias = (
_GraphCallbackManager
| _AsyncGraphCallbackManager
| BaseCallbackManager
| GraphCallbackHandler
| Sequence[BaseCallbackHandler]
| Sequence[GraphCallbackHandler]
| None
)
def get_sync_graph_callback_manager_for_config(
config: RunnableConfig,
*,
run_id: UUID | None = None,
) -> _GraphCallbackManager:
"""Build a sync graph lifecycle callback manager from a runnable config.
This helper filters `config["callbacks"]` down to handlers that inherit
from `GraphCallbackHandler` and binds the provided `run_id` onto the
returned manager.
"""
return _GraphCallbackManager.configure(
config.get("callbacks"),
run_id=run_id,
)
def get_async_graph_callback_manager_for_config(
config: RunnableConfig,
*,
run_id: UUID | None = None,
) -> _AsyncGraphCallbackManager:
"""Build an async graph lifecycle callback manager from a runnable config.
This helper filters `config["callbacks"]` down to handlers that inherit
from `GraphCallbackHandler` and binds the provided `run_id` onto the
returned manager.
"""
return _AsyncGraphCallbackManager.configure(
config.get("callbacks"),
run_id=run_id,
)
@@ -1,6 +1,7 @@
from langgraph.channels.any_value import AnyValue
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
from langgraph.channels.named_barrier_value import (
@@ -20,6 +21,7 @@ __all__ = (
"UntrackedValue",
"EphemeralValue",
"BinaryOperatorAggregate",
"DeltaChannel",
"NamedBarrierValue",
"NamedBarrierValueAfterFinish",
# topics
+16 -9
View File
@@ -22,10 +22,9 @@ __all__ = ("BinaryOperatorAggregate",)
def _strip_extras(t): # type: ignore[no-untyped-def]
"""Strips Annotated, Required and NotRequired from a given type."""
if hasattr(t, "__origin__"):
if t.__origin__ in (Required, NotRequired):
return _strip_extras(t.__args__[0])
return _strip_extras(t.__origin__)
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
return _strip_extras(t.__args__[0])
return t
@@ -33,11 +32,22 @@ def _get_overwrite(value: Any) -> tuple[bool, Any]:
"""Inspects the given value and returns (is_overwrite, overwrite_value)."""
if isinstance(value, Overwrite):
return True, value.value
if isinstance(value, dict) and set(value.keys()) == {OVERWRITE}:
if isinstance(value, dict) and len(value) == 1 and OVERWRITE in value:
return True, value[OVERWRITE]
return False, None
def _operators_equal(a: Callable, b: Callable) -> bool:
"""Return True if two reducer operators should be considered equal.
Lambdas all share the name '<lambda>' so identity comparison is
unreliable; treat any pairing that includes a lambda as equal.
"""
if a.__name__ == "<lambda>" or b.__name__ == "<lambda>":
return True
return a is b
class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
"""Stores the result of applying a binary operator to the current value and each new value.
@@ -68,11 +78,8 @@ class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
self.value = MISSING
def __eq__(self, value: object) -> bool:
return isinstance(value, BinaryOperatorAggregate) and (
value.operator is self.operator
if value.operator.__name__ != "<lambda>"
and self.operator.__name__ != "<lambda>"
else True
return isinstance(value, BinaryOperatorAggregate) and _operators_equal(
self.operator, value.operator
)
@property
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import collections.abc
import copy as _copy
from collections.abc import Callable, Sequence
from typing import Any, Generic
from langgraph.checkpoint.base import DELTA_SENTINEL, PendingWrite
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import Self
from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel, Value
from langgraph.channels.binop import _get_overwrite, _operators_equal, _strip_extras
from langgraph.errors import (
EmptyChannelError,
ErrorCode,
InvalidUpdateError,
create_error_message,
)
__all__ = ("DeltaChannel",)
class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
"""Reducer channel that stores only a sentinel in checkpoint blobs and
reconstructs state by replaying ancestor writes through the reducer.
The reducer receives the current accumulated value and a batch of writes
in one call: `reducer(state, [write1, write2, ...]) -> new_state`.
Reducers must be deterministic and batching-invariant (associative across
folds): applying two consecutive write batches separately must produce the
same state as applying their concatenation once:
reducer(reducer(state, xs), ys) == reducer(state, xs + ys)
This lets LangGraph replay checkpointed writes in larger batches than they
were originally produced without changing reconstructed state.
`snapshot_frequency=None` (default): pure delta; stores only
`DELTA_SENTINEL` in checkpoint blobs; reads replay all ancestor writes.
`snapshot_frequency=N`: `create_checkpoint` writes a full `_DeltaSnapshot`
blob every N steps, bounding replay depth to N.
Parameters:
reducer: `(state, list[writes]) -> new_state`. Must be deterministic
and batching-invariant as described above.
typ: The value type (e.g. `list`, `dict`). Inferred automatically
from the outer type when used inside `Annotated[T, DeltaChannel(...)]`.
snapshot_frequency: Every Nth pregel step writes a snapshot blob.
`None` (default) = pure delta, never snapshot.
"""
__slots__ = ("value", "reducer", "snapshot_frequency")
value: Value | Any
def __init__(
self,
reducer: Callable[[Any, Sequence[Any]], Any],
typ: type[Value] | None = None,
*,
snapshot_frequency: int | None = None,
) -> None:
if typ is None:
typ = list # type: ignore[assignment] # placeholder; overridden by _is_field_channel
super().__init__(typ)
self.reducer = reducer
self.snapshot_frequency = snapshot_frequency
typ = _strip_extras(typ)
if typ in (collections.abc.Sequence, collections.abc.MutableSequence):
typ = list
if typ in (collections.abc.Set, collections.abc.MutableSet):
typ = set
if typ in (collections.abc.Mapping, collections.abc.MutableMapping):
typ = dict
self.typ = typ
self.value: Any = MISSING
def __eq__(self, other: object) -> bool:
if not isinstance(other, DeltaChannel):
return False
if self.snapshot_frequency != other.snapshot_frequency:
return False
return _operators_equal(self.reducer, other.reducer)
@property
def ValueType(self) -> Any:
return self.typ
@property
def UpdateType(self) -> Any:
return self.typ
def is_snapshot_step(self, step: int) -> bool:
"""True if pregel should write a snapshot blob at this step."""
return (
self.snapshot_frequency is not None
and step > 0
and step % self.snapshot_frequency == 0
)
def copy(self) -> Self:
new = self.__class__(
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
)
new.key = self.key
new.value = self.value if self.value is MISSING else _copy.copy(self.value)
return new
def from_checkpoint(self, checkpoint: Any) -> Self:
"""Initialize from a stored blob or sentinel.
Blob types (dispatched via serde ext code, not dict key inspection):
* `DELTA_SENTINEL` / `MISSING`: start empty; caller replays writes.
* `_DeltaSnapshot(value)`: restore value directly from snapshot.
* plain value (migration from old BinOp blobs): use directly.
"""
new = self.__class__(
self.reducer, self.typ, snapshot_frequency=self.snapshot_frequency
)
new.key = self.key
if checkpoint is MISSING or checkpoint is DELTA_SENTINEL:
new.value = self.typ()
elif isinstance(checkpoint, _DeltaSnapshot):
new.value = checkpoint.value
else:
new.value = checkpoint
return new
def replay_writes(self, writes: Sequence[PendingWrite]) -> None:
"""Apply ancestor writes oldest-to-newest via a single reducer call.
If any write is an Overwrite, the last one in the sequence acts as
the reset point: its value becomes the new base and only writes
after it are passed to the reducer.
"""
values = [v for _, _, v in writes]
if not values:
return
base = self.value
start = 0
for i, v in enumerate(values):
is_ow, ow_value = _get_overwrite(v)
if is_ow:
base = _copy.copy(ow_value) if ow_value is not None else self.typ()
start = i + 1
remaining = values[start:]
self.value = self.reducer(base, remaining) if remaining else base
def update(self, values: Sequence[Any]) -> bool:
if not values:
return False
overwrite_idx: int | None = None
for i, v in enumerate(values):
is_ow, _ = _get_overwrite(v)
if is_ow:
if overwrite_idx is not None:
msg = create_error_message(
message="Can receive only one Overwrite value per super-step.",
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
)
raise InvalidUpdateError(msg)
overwrite_idx = i
if overwrite_idx is not None:
_, overwrite_value = _get_overwrite(values[overwrite_idx])
base = (
_copy.copy(overwrite_value)
if overwrite_value is not None
else self.typ()
)
remaining = [v for i, v in enumerate(values) if i != overwrite_idx]
self.value = self.reducer(base, remaining) if remaining else base
return True
base = self.typ() if self.value is MISSING else self.value
self.value = self.reducer(base, list(values))
return True
def get(self) -> Any:
if self.value is MISSING:
raise EmptyChannelError()
return self.value
def is_available(self) -> bool:
return self.value is not MISSING
def checkpoint(self) -> Any:
"""Return stored representation: always `DELTA_SENTINEL`.
Snapshot decisions are made by `create_checkpoint` in pregel (which
has the step number) via `is_snapshot_step`. `checkpoint()` is only
called for non-snapshot steps or when no checkpointer is available.
"""
if self.value is MISSING:
return MISSING
return DELTA_SENTINEL
+46
View File
@@ -244,6 +244,52 @@ def add_messages(
return merged
def _messages_delta_reducer(
state: list[AnyMessage], writes: list[list[AnyMessage]]
) -> list[AnyMessage]:
"""**Experimental.** Batch reducer for use with `DeltaChannel`.
Processes all writes in one pass dedup by ID, `RemoveMessage`
tombstoning without calling `add_messages`. Assumes writes contain
already-typed `BaseMessage` objects (no raw-dict coercion).
This reducer is batching-invariant, as required by `DeltaChannel`:
`reducer(reducer(state, xs), ys) == reducer(state, xs + ys)`.
Use `add_messages` as the reducer for `BinaryOperatorAggregate` or
anywhere raw message dicts / strings need to be coerced first.
Example::
from typing import Annotated
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
"""
from itertools import chain
index: dict[str, int] = {m.id: i for i, m in enumerate(state) if m.id is not None}
result: list[AnyMessage | None] = list(state)
for msg in chain.from_iterable(
[w] if isinstance(w, BaseMessage) else w for w in writes
):
mid = msg.id
if mid is None:
result.append(msg)
elif isinstance(msg, RemoveMessage):
if mid in index:
result[index[mid]] = None
del index[mid]
elif mid in index:
result[index[mid]] = msg
else:
index[mid] = len(result)
result.append(msg)
return [m for m in result if m is not None]
@deprecated(
"MessageGraph is deprecated in langgraph 1.0.0, to be removed in 2.0.0. Please use StateGraph with a `messages` key instead.",
category=None,
+16
View File
@@ -48,6 +48,7 @@ from langgraph._internal._runnable import coerce_to_runnable
from langgraph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue, LastValueAfterFinish
from langgraph.channels.named_barrier_value import (
@@ -1082,6 +1083,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
CompiledStateGraph: The compiled `StateGraph`.
"""
checkpointer = ensure_valid_checkpointer(checkpointer)
serde_allowlist: set[tuple[str, ...]] | None = None
if _serde.STRICT_MSGPACK_ENABLED:
schema_types: list[type[Any]] = [
@@ -1667,6 +1669,20 @@ def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
# Search through all annotated medata to find channel annotations
for item in meta:
if isinstance(item, BaseChannel):
if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"):
origin = typ.__origin__
# Unwrap parameterized Required[X]/NotRequired[X] to X
# (e.g. Annotated[NotRequired[dict[...]], ...]).
if hasattr(origin, "__origin__") and origin.__origin__ in (
Required,
NotRequired,
):
origin = origin.__args__[0]
item = item.__class__(
item.reducer,
origin,
snapshot_frequency=item.snapshot_frequency,
)
return item
elif isclass(item) and issubclass(item, BaseChannel):
# ex, Annotated[int, EphemeralValue, SomeOtherAnnotation]
+114 -16
View File
@@ -1,17 +1,23 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from typing import Any, cast
from langgraph.checkpoint.base import Checkpoint
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import DELTA_SENTINEL, BaseCheckpointSaver, Checkpoint
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel
from langgraph.managed.base import ManagedValueMapping, ManagedValueSpec
LATEST_VERSION = 4
GetNextVersion = Callable[[Any, None], Any]
def empty_checkpoint() -> Checkpoint:
return Checkpoint(
@@ -31,35 +37,87 @@ def create_checkpoint(
*,
id: str | None = None,
updated_channels: set[str] | None = None,
get_next_version: GetNextVersion | None = None,
force_delta_snapshot: bool = False,
) -> Checkpoint:
"""Create a checkpoint for the given channels."""
"""Create a checkpoint for the given channels.
For `DeltaChannel` with `snapshot_frequency=N`, snapshot steps write a
`_DeltaSnapshot` blob rather than `DELTA_SENTINEL`, bounding the ancestor
walk to at most N steps. Snapshots are eager: even if the channel had no
write this step, a version bump is forced (via `get_next_version`) so the
blob is stored by `put()`. Without `get_next_version` (e.g. static
contexts), snapshot steps gracefully fall back to sentinel.
`force_delta_snapshot` writes available `DeltaChannel` values as snapshots
regardless of `snapshot_frequency`. This is used by `durability="exit"`,
where intermediate writes are not stored as ancestor `checkpoint_writes`.
"""
ts = datetime.now(timezone.utc).isoformat()
if channels is None:
values = checkpoint["channel_values"]
channel_versions = checkpoint["channel_versions"]
else:
values = {}
channel_versions = dict(checkpoint["channel_versions"])
for k in channels:
if k not in checkpoint["channel_versions"]:
if k not in channel_versions:
continue
v = channels[k].checkpoint()
if v is not MISSING:
values[k] = v
ch = channels[k]
if (
isinstance(ch, DeltaChannel)
and (force_delta_snapshot or ch.is_snapshot_step(step))
and ch.is_available()
):
# Eager snapshot: bump version if not already written this step
# so put() includes this channel in new_versions and stores blob.
if get_next_version is not None and (
updated_channels is None or k not in updated_channels
):
channel_versions[k] = get_next_version(channel_versions[k], None)
values[k] = _DeltaSnapshot(ch.get())
else:
v = ch.checkpoint()
if v is not MISSING:
values[k] = v
return Checkpoint(
v=LATEST_VERSION,
ts=ts,
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=checkpoint["channel_versions"],
channel_versions=channel_versions,
versions_seen=checkpoint["versions_seen"],
updated_channels=None if updated_channels is None else sorted(updated_channels),
)
def _needs_replay(spec: BaseChannel, stored: object) -> bool:
"""True if `spec` is a `DeltaChannel` and the stored blob is a sentinel,
requiring an ancestor walk to reconstruct.
`_DeltaSnapshot` blobs and plain values (migration) resolve directly via
`from_checkpoint` only `DELTA_SENTINEL` / `MISSING` trigger replay.
"""
if not isinstance(spec, DeltaChannel):
return False
return stored is MISSING or stored is DELTA_SENTINEL
def channels_from_checkpoint(
specs: Mapping[str, BaseChannel | ManagedValueSpec],
checkpoint: Checkpoint,
*,
saver: BaseCheckpointSaver | None = None,
config: RunnableConfig | None = None,
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
"""Get channels from a checkpoint."""
"""Hydrate channels from a checkpoint.
For most channels, `spec.from_checkpoint(checkpoint["channel_values"][k])`
is sufficient. `DeltaChannel` is the exception: sentinel blobs require an
ancestor walk via `saver._get_channel_writes_history`. The walk terminates
at the nearest `_DeltaSnapshot` blob (step-based) or a pre-migration plain
value, so read depth is bounded by `snapshot_frequency`.
"""
channel_specs: dict[str, BaseChannel] = {}
managed_specs: dict[str, ManagedValueSpec] = {}
for k, v in specs.items():
@@ -67,13 +125,53 @@ def channels_from_checkpoint(
channel_specs[k] = v
else:
managed_specs[k] = v
return (
{
k: v.from_checkpoint(checkpoint["channel_values"].get(k, MISSING))
for k, v in channel_specs.items()
},
managed_specs,
)
channels: dict[str, BaseChannel] = {}
for k, spec in channel_specs.items():
ch: BaseChannel
stored = checkpoint["channel_values"].get(k, MISSING)
if _needs_replay(spec, stored) and saver is not None and config is not None:
delta_spec = cast(DeltaChannel, spec)
history = saver._get_channel_writes_history(config, k)
replay_ch = delta_spec.from_checkpoint(history.seed)
replay_ch.replay_writes(history.writes)
ch = replay_ch
else:
ch = spec.from_checkpoint(stored)
channels[k] = ch
return channels, managed_specs
async def achannels_from_checkpoint(
specs: Mapping[str, BaseChannel | ManagedValueSpec],
checkpoint: Checkpoint,
*,
saver: BaseCheckpointSaver | None = None,
config: RunnableConfig | None = None,
) -> tuple[Mapping[str, BaseChannel], ManagedValueMapping]:
"""Async version of `channels_from_checkpoint`. See docstring there."""
channel_specs: dict[str, BaseChannel] = {}
managed_specs: dict[str, ManagedValueSpec] = {}
for k, v in specs.items():
if isinstance(v, BaseChannel):
channel_specs[k] = v
else:
managed_specs[k] = v
channels: dict[str, BaseChannel] = {}
for k, spec in channel_specs.items():
ch: BaseChannel
stored = checkpoint["channel_values"].get(k, MISSING)
if _needs_replay(spec, stored) and saver is not None and config is not None:
delta_spec = cast(DeltaChannel, spec)
history = await saver._aget_channel_writes_history(config, k)
replay_ch = delta_spec.from_checkpoint(history.seed)
replay_ch.replay_writes(history.writes)
ch = replay_ch
else:
ch = spec.from_checkpoint(stored)
channels[k] = ch
return channels, managed_specs
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
+129 -16
View File
@@ -62,7 +62,13 @@ from langgraph._internal._constants import (
from langgraph._internal._replay import ReplayState
from langgraph._internal._scratchpad import PregelScratchpad
from langgraph._internal._typing import EMPTY_SEQ, MISSING
from langgraph.callbacks import (
GraphInterruptEvent,
GraphLifecycleEvent,
GraphResumeEvent,
)
from langgraph.channels.base import BaseChannel
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.untracked_value import UntrackedValue
from langgraph.constants import TAG_HIDDEN
from langgraph.errors import (
@@ -87,6 +93,7 @@ from langgraph.pregel._algo import (
task_path_str,
)
from langgraph.pregel._checkpoint import (
achannels_from_checkpoint,
channels_from_checkpoint,
copy_checkpoint,
create_checkpoint,
@@ -117,6 +124,7 @@ from langgraph.types import (
CachePolicy,
Command,
Durability,
Interrupt,
PregelExecutableTask,
RetryPolicy,
Send,
@@ -182,6 +190,8 @@ class PregelLoop:
_migrate_checkpoint: Callable[[Checkpoint], None] | None
submit: Submit
channels: Mapping[str, BaseChannel]
# Only set on AsyncPregelLoop; sync loops keep this as None.
_delta_write_futs: list[Any] | None = None
managed: ManagedValueMapping
checkpoint: Checkpoint
checkpoint_id_saved: str
@@ -203,6 +213,8 @@ class PregelLoop:
tasks: dict[str, PregelExecutableTask]
output: None | dict[str, Any] | Any = None
updated_channels: set[str] | None = None
_graph_lifecycle_events: deque[GraphLifecycleEvent]
_has_graph_lifecycle_callbacks: bool
# public
@@ -228,6 +240,7 @@ class PregelLoop:
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None,
has_graph_lifecycle_callbacks: bool = False,
) -> None:
self.stream = stream
self.config = config
@@ -252,6 +265,8 @@ class PregelLoop:
self.retry_policy = retry_policy
self.cache_policy = cache_policy
self.durability = durability
self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
self._graph_lifecycle_events = deque()
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
scratchpad: PregelScratchpad | None = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
@@ -303,6 +318,40 @@ class PregelLoop:
)
self.prev_checkpoint_config = None
def _push_graph_lifecycle_event(
self,
kind: Literal["resume", "interrupt"],
*,
interrupts: tuple[Interrupt, ...] = (),
) -> None:
if kind == "resume":
self._graph_lifecycle_events.append(
GraphResumeEvent(
run_id=None,
status=self.status,
checkpoint_id=self.checkpoint["id"],
checkpoint_ns=self.checkpoint_ns,
)
)
elif kind == "interrupt":
self._graph_lifecycle_events.append(
GraphInterruptEvent(
run_id=None,
status=self.status,
checkpoint_id=self.checkpoint["id"],
checkpoint_ns=self.checkpoint_ns,
interrupts=interrupts,
)
)
else:
msg = f"Unknown graph lifecycle event type: {kind}"
raise AssertionError(msg)
def _pop_lifecycle_event(self) -> GraphLifecycleEvent | None:
if not self._graph_lifecycle_events:
return None
return self._graph_lifecycle_events.popleft()
def put_writes(self, task_id: str, writes: WritesT) -> None:
"""Put writes for a task, to be read by the next tick."""
if not writes:
@@ -361,7 +410,7 @@ class PregelLoop:
task = self.tasks.get(task_id)
else:
task = None
self.submit(
fut = self.submit(
self.checkpointer_put_writes,
config,
writes_to_save,
@@ -369,12 +418,16 @@ class PregelLoop:
task_path_str(task.path) if task else "",
)
else:
self.submit(
fut = self.submit(
self.checkpointer_put_writes,
config,
writes_to_save,
task_id,
)
if self._delta_write_futs is not None and any(
isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
):
self._delta_write_futs.append(fut)
# output writes
if hasattr(self, "tasks"):
self.output_writes(task_id, writes)
@@ -647,7 +700,7 @@ class PregelLoop:
# writes so that interrupt() calls re-fire instead of returning
# stale values. But if we're actively resuming, keep them —
# multi-interrupt scenarios need previously resolved values preserved.
if self.is_replaying and (
is_time_traveling = self.is_replaying and (
# Time-travel to a subgraph checkpoint: the parent sets
# RESUMING=True (it can't distinguish time-travel from resume),
# so we check if this subgraph's own ns is in checkpoint_map.
@@ -665,7 +718,8 @@ class PregelLoop:
# (subgraph input is a Send arg, not a Command)
or configurable.get(CONFIG_KEY_RESUMING, False)
)
):
)
if is_time_traveling:
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != RESUME
]
@@ -720,6 +774,26 @@ class PregelLoop:
if k in self.checkpoint["channel_versions"]:
version = self.checkpoint["channel_versions"][k]
self.checkpoint["versions_seen"][INTERRUPT][k] = version
# When time-traveling (replaying from a specific checkpoint),
# save a fork checkpoint so the replayed execution creates a
# new branch. Without this, if the execution hits an interrupt
# before after_tick() runs, no new checkpoint is created —
# the parent's latest checkpoint remains the old one and
# subsequent resumes load the wrong state.
# Skip for update_state forks (source=update/fork) since they
# already have their own fork checkpoint.
if is_time_traveling and self.checkpoint_metadata.get("source") not in (
"update",
"fork",
):
# Clear old INTERRUPT writes from the loaded checkpoint.
# The fork will have a new checkpoint_id which changes
# task IDs — stale interrupt writes would accumulate and
# confuse the multiple-interrupt check in future resumes.
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != INTERRUPT
]
self._put_checkpoint({"source": "fork"})
# produce values output
self._emit(
"values", map_output_values, self.output_keys, True, self.channels
@@ -762,14 +836,28 @@ class PregelLoop:
if not self.is_nested:
# Pass the resolved before-bound checkpoint ID so subgraphs can
# find their corresponding checkpoint without re-fetching the
# parent. For forks (source=update), use the fork's parent
# parent. For forks (source=update/fork), use the fork's parent
# checkpoint ID since the fork was created after the subgraph's
# checkpoints from the original execution.
#
# Only gate on is_time_traveling (not is_replaying). When the
# client resumes with an explicit checkpoint_id that happens to
# point at the current head (e.g. LangGraph Studio sending
# `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
# is_replaying is True but is_time_traveling is False. In that
# case subgraphs should load their latest checkpoint normally,
# not go through ReplayState's before-bound lookup which would
# miss subgraph checkpoints created during processing of the
# current parent step.
replay_state: ReplayState | None = None
if self.is_replaying:
if is_time_traveling:
replay_checkpoint_id = self.checkpoint["id"]
if (
self.checkpoint_metadata.get("source") == "update"
self.checkpoint_metadata.get("source")
in (
"update",
"fork",
)
and self.prev_checkpoint_config
):
replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
@@ -785,6 +873,8 @@ class PregelLoop:
)
# set flag
self.status = "pending"
if is_resuming:
self._push_graph_lifecycle_event("resume")
return updated_channels
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
@@ -808,6 +898,10 @@ class PregelLoop:
self.step,
id=self.checkpoint["id"] if exiting else None,
updated_channels=self.updated_channels,
get_next_version=self.checkpointer_get_next_version
if do_checkpoint
else None,
force_delta_snapshot=exiting and self.durability == "exit",
)
# sanitize TASK channel in the checkpoint before saving (durability=="exit")
if TASKS in self.checkpoint["channel_values"] and any(
@@ -885,8 +979,10 @@ class PregelLoop:
self._put_checkpoint(self.checkpoint_metadata)
self._put_pending_writes()
# suppress interrupt
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
if suppress:
if isinstance(exc_value, GraphInterrupt) and not self.is_nested:
interrupt = exc_value
interrupts = tuple(interrupt.args[0]) if interrupt.args else ()
self._push_graph_lifecycle_event("interrupt", interrupts=interrupts)
# emit one last "values" event, with pending writes applied
if (
hasattr(self, "tasks")
@@ -913,12 +1009,11 @@ class PregelLoop:
self.channels,
)
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
if not interrupt.args or not interrupt.args[0]:
interrupt_payload = interrupt.args[0] if interrupt.args else ()
self._emit(
"updates",
lambda: iter(
[{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]
),
lambda: iter([{INTERRUPT: interrupt_payload}]),
)
# save final output
self.output = read_channels(self.channels, self.output_keys)
@@ -1040,6 +1135,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None,
has_graph_lifecycle_callbacks: bool = False,
) -> None:
super().__init__(
input,
@@ -1061,6 +1157,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
retry_policy=retry_policy,
cache_policy=cache_policy,
durability=durability,
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
)
self.stack = ExitStack()
if checkpointer:
@@ -1136,6 +1233,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
# context manager
def __enter__(self) -> Self:
self._graph_lifecycle_events = deque()
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
@@ -1187,7 +1285,10 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
)
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
self.specs,
self.checkpoint,
saver=self.checkpointer,
config=self.checkpoint_config,
)
self.stack.push(self._suppress_interrupt)
self.status = "input"
@@ -1236,6 +1337,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
retry_policy: Sequence[RetryPolicy] = (),
cache_policy: CachePolicy | None = None,
has_graph_lifecycle_callbacks: bool = False,
) -> None:
super().__init__(
input,
@@ -1257,6 +1359,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
retry_policy=retry_policy,
cache_policy=cache_policy,
durability=durability,
has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
)
self.stack = AsyncExitStack()
if checkpointer:
@@ -1280,6 +1383,11 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
# Drain DeltaChannel write futures before committing the checkpoint so
# DELTA_SENTINEL blobs are never saved ahead of their backing writes.
if self._delta_write_futs:
futs, self._delta_write_futs = self._delta_write_futs, []
await asyncio.gather(*futs)
try:
if prev is not None:
await prev
@@ -1335,6 +1443,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
# context manager
async def __aenter__(self) -> Self:
self._graph_lifecycle_events = deque()
if not self.checkpointer:
saved = None
elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
@@ -1384,11 +1493,15 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
if saved.pending_writes is not None
else []
)
self._delta_write_futs = []
self.submit = await self.stack.enter_async_context(
AsyncBackgroundExecutor(self.config)
)
self.channels, self.managed = channels_from_checkpoint(
self.specs, self.checkpoint
self.channels, self.managed = await achannels_from_checkpoint(
self.specs,
self.checkpoint,
saver=self.checkpointer,
config=self.checkpoint_config,
)
self.stack.push(self._suppress_interrupt)
self.status = "input"
+82 -8
View File
@@ -16,7 +16,7 @@ from collections.abc import (
Mapping,
Sequence,
)
from dataclasses import is_dataclass
from dataclasses import is_dataclass, replace
from functools import partial
from inspect import isclass
from typing import (
@@ -96,6 +96,12 @@ from langgraph._internal._runnable import (
coerce_to_runnable,
)
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.callbacks import (
GraphInterruptEvent,
GraphResumeEvent,
get_async_graph_callback_manager_for_config,
get_sync_graph_callback_manager_for_config,
)
from langgraph.channels.base import BaseChannel
from langgraph.channels.topic import Topic
from langgraph.config import get_config
@@ -116,6 +122,7 @@ from langgraph.pregel._algo import (
)
from langgraph.pregel._call import identifier
from langgraph.pregel._checkpoint import (
achannels_from_checkpoint,
channels_from_checkpoint,
copy_checkpoint,
create_checkpoint,
@@ -1046,6 +1053,10 @@ class Pregel(
channels, managed = channels_from_checkpoint(
self.channels,
saved.checkpoint,
saver=self.checkpointer
if isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config,
)
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
@@ -1162,9 +1173,13 @@ class Pregel(
step = saved.metadata.get("step", -1) + 1
stop = step + 2
channels, managed = channels_from_checkpoint(
channels, managed = await achannels_from_checkpoint(
self.channels,
saved.checkpoint,
saver=self.checkpointer
if isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config,
)
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
@@ -1535,6 +1550,11 @@ class Pregel(
channels, managed = channels_from_checkpoint(
self.channels,
checkpoint,
saver=self.checkpointer
if saved is not None
and isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config if saved is not None else None,
)
values, as_node = updates[0][:2]
@@ -1978,9 +1998,14 @@ class Pregel(
)
if saved:
checkpoint_config = patch_configurable(config, saved.config[CONF])
channels, managed = channels_from_checkpoint(
channels, managed = await achannels_from_checkpoint(
self.channels,
checkpoint,
saver=self.checkpointer
if saved is not None
and isinstance(self.checkpointer, BaseCheckpointSaver)
else None,
config=saved.config if saved is not None else None,
)
values, as_node = updates[0][:2]
# no values, just clear all tasks
@@ -2585,6 +2610,10 @@ class Pregel(
name=config.get("run_name", self.get_name()),
run_id=config.get("run_id"),
)
graph_callback_manager = get_sync_graph_callback_manager_for_config(
config,
run_id=run_manager.run_id,
)
try:
# assign defaults
(
@@ -2669,6 +2698,17 @@ class Pregel(
_output_mapper = self._output_mapper if version == "v2" else None
_state_mapper = self._state_mapper if version == "v2" else None
def emit_graph_lifecycle_events(loop: SyncPregelLoop) -> None:
while (event := loop._pop_lifecycle_event()) is not None:
if isinstance(event, GraphResumeEvent):
graph_callback_manager.on_resume(
replace(event, run_id=graph_callback_manager.run_id)
)
else:
graph_callback_manager.on_interrupt(
replace(event, run_id=graph_callback_manager.run_id)
)
with SyncPregelLoop(
input,
stream=StreamProtocol(stream.put, stream_modes),
@@ -2689,7 +2729,9 @@ class Pregel(
migrate_checkpoint=self._migrate_checkpoint,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop:
emit_graph_lifecycle_events(loop)
# create runner
runner = PregelRunner(
submit=config[CONF].get(
@@ -2751,9 +2793,11 @@ class Pregel(
_state_mapper,
)
loop.after_tick()
emit_graph_lifecycle_events(loop)
# wait for checkpoint
if durability_ == "sync":
loop._put_checkpoint_fut.result()
emit_graph_lifecycle_events(loop)
# emit output
yield from _output(
stream_mode,
@@ -2928,6 +2972,10 @@ class Pregel(
name=config.get("run_name", self.get_name()),
run_id=config.get("run_id"),
)
graph_callback_manager = get_async_graph_callback_manager_for_config(
config,
run_id=run_manager.run_id,
)
# if running from astream_log() run each proc with streaming
do_stream = (
next(
@@ -3042,6 +3090,28 @@ class Pregel(
_output_mapper = self._output_mapper if version == "v2" else None
_state_mapper = self._state_mapper if version == "v2" else None
async def aemit_graph_lifecycle_events(loop: AsyncPregelLoop) -> None:
while (event := loop._pop_lifecycle_event()) is not None:
if isinstance(event, GraphResumeEvent):
await graph_callback_manager.on_resume(
GraphResumeEvent(
run_id=graph_callback_manager.run_id,
status=event.status,
checkpoint_id=event.checkpoint_id,
checkpoint_ns=event.checkpoint_ns,
)
)
else:
await graph_callback_manager.on_interrupt(
GraphInterruptEvent(
run_id=graph_callback_manager.run_id,
status=event.status,
checkpoint_id=event.checkpoint_id,
checkpoint_ns=event.checkpoint_ns,
interrupts=event.interrupts,
)
)
async with AsyncPregelLoop(
input,
stream=StreamProtocol(stream.put_nowait, stream_modes),
@@ -3062,7 +3132,9 @@ class Pregel(
migrate_checkpoint=self._migrate_checkpoint,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop:
await aemit_graph_lifecycle_events(loop)
# create runner
runner = PregelRunner(
submit=config[CONF].get(
@@ -3144,6 +3216,7 @@ class Pregel(
):
yield o
loop.after_tick()
await aemit_graph_lifecycle_events(loop)
# wait for checkpoint
if durability_ == "sync":
await cast(asyncio.Future, loop._put_checkpoint_fut)
@@ -3152,6 +3225,8 @@ class Pregel(
if _cleanup_waiter is not None:
await _cleanup_waiter()
await aemit_graph_lifecycle_events(loop)
# emit output
for o in _output(
stream_mode,
@@ -3659,15 +3734,14 @@ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> Non
def _build_server_info(
config: RunnableConfig, parent_runtime: Runtime[Any]
) -> ServerInfo | None:
"""Build ServerInfo from config metadata and configurable.
"""Build ServerInfo from config configurable.
The server puts assistant_id/graph_id in config metadata and the
The server puts assistant_id/graph_id in config configurable and the
authenticated user dict in configurable["langgraph_auth_user"].
"""
metadata = config.get("metadata") or {}
configurable = config.get(CONF) or {}
assistant_id = metadata.get("assistant_id")
graph_id = metadata.get("graph_id")
assistant_id = configurable.get("assistant_id")
graph_id = configurable.get("graph_id")
# Read authenticated user from configurable (set by LangGraph Server).
# We prefer isinstance(BaseUser) but fall back to hasattr("identity")
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.1.6"
version = "1.2.0a2"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -24,8 +24,8 @@ classifiers = [
'Programming Language :: Python :: 3.13',
]
dependencies = [
"langchain-core>=0.1",
"langgraph-checkpoint>=2.1.0,<5.0.0",
"langchain-core>=1.3.2,<2",
"langgraph-checkpoint>=4.0.3,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-prebuilt>=1.0.9,<1.1.0",
"xxhash>=3.5.0",
+539 -5
View File
@@ -1,18 +1,34 @@
import operator
from collections.abc import Sequence
from typing import Annotated
import pytest
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
from langgraph.checkpoint.base import DELTA_SENTINEL
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.types import _DeltaSnapshot
from typing_extensions import NotRequired, TypedDict
from langgraph._internal._typing import MISSING
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
from langgraph.channels.untracked_value import UntrackedValue
from langgraph.errors import EmptyChannelError, InvalidUpdateError
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.graph.state import _get_channel
from langgraph.types import Overwrite
pytestmark = pytest.mark.anyio
# ---------------------------------------------------------------------------
# Core channel primitives
# ---------------------------------------------------------------------------
def test_last_value() -> None:
channel = LastValue(int).from_checkpoint(MISSING)
assert channel.ValueType is int
@@ -95,25 +111,543 @@ def test_untracked_value() -> None:
assert channel.ValueType is dict
assert channel.UpdateType is dict
# UntrackedValue should start empty
with pytest.raises(EmptyChannelError):
channel.get()
# Should be able to update with a value
test_data = {"session": "test", "temp": "dir"}
channel.update([test_data])
assert channel.get() == test_data
# Update with new value
new_data = {"session": "updated", "temp": "newdir"}
channel.update([new_data])
assert channel.get() == new_data
# On checkpoint, UntrackedValue should return MISSING
checkpoint = channel.checkpoint()
assert checkpoint is MISSING
# Creating from checkpoint with MISSING should start empty
new_channel = UntrackedValue(dict).from_checkpoint(checkpoint)
with pytest.raises(EmptyChannelError):
new_channel.get()
# ---------------------------------------------------------------------------
# DeltaChannel — message reducer
# ---------------------------------------------------------------------------
def test_delta_channel_basic_two_steps() -> None:
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
ch.update([HumanMessage(content="hi", id="h1")])
d1 = ch.checkpoint()
assert d1 is DELTA_SENTINEL
ch.update([AIMessage(content="hello", id="a1")])
d2 = ch.checkpoint()
assert d2 is DELTA_SENTINEL
assert len(ch.get()) == 2
assert ch.get()[0].content == "hi"
assert ch.get()[1].content == "hello"
def test_delta_channel_from_checkpoint_writes_list() -> None:
"""replay_writes on a fresh channel replays through the operator."""
spec = DeltaChannel(_messages_delta_reducer, list)
ch = spec.from_checkpoint(DELTA_SENTINEL)
ch.replay_writes(
[
("t0", "messages", HumanMessage(content="hi", id="h1")),
("t1", "messages", AIMessage(content="hello", id="a1")),
("t2", "messages", HumanMessage(content="bye", id="h2")),
]
)
msgs = ch.get()
assert len(msgs) == 3
assert msgs[0].content == "hi"
assert msgs[1].content == "hello"
assert msgs[2].content == "bye"
def test_delta_channel_from_checkpoint_backwards_compat() -> None:
spec = DeltaChannel(_messages_delta_reducer, list)
old_value = [HumanMessage(content="old", id="h1")]
ch = spec.from_checkpoint(old_value)
assert ch.get() == old_value
def test_delta_channel_overwrite() -> None:
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
ch.update([HumanMessage(content="old", id="h1")])
ch.update([Overwrite([HumanMessage(content="new", id="h2")])])
d = ch.checkpoint()
assert d is DELTA_SENTINEL
assert len(ch.get()) == 1
assert ch.get()[0].content == "new"
def test_delta_channel_remove_message_and_replay() -> None:
"""RemoveMessage must round-trip correctly when writes are replayed."""
spec = DeltaChannel(_messages_delta_reducer, list)
ch = spec.from_checkpoint(MISSING)
ch.update([HumanMessage(content="hi", id="h1")])
ch.update([AIMessage(content="hello", id="a1")])
assert ch.get() == [
HumanMessage(content="hi", id="h1"),
AIMessage(content="hello", id="a1"),
]
ch.update([RemoveMessage(id="a1")])
assert ch.get() == [HumanMessage(content="hi", id="h1")]
ch2 = spec.from_checkpoint(DELTA_SENTINEL)
ch2.replay_writes(
[
("t0", "messages", HumanMessage(content="hi", id="h1")),
("t1", "messages", AIMessage(content="hello", id="a1")),
("t2", "messages", RemoveMessage(id="a1")),
]
)
assert ch2.get() == [HumanMessage(content="hi", id="h1")]
def test_delta_channel_update_by_id_and_replay() -> None:
"""Updating a message by ID must round-trip correctly through writes replay."""
spec = DeltaChannel(_messages_delta_reducer, list)
ch = spec.from_checkpoint(MISSING)
ch.update([HumanMessage(content="original", id="h1")])
ch.update([HumanMessage(content="updated", id="h1")])
assert ch.get() == [HumanMessage(content="updated", id="h1")]
ch2 = spec.from_checkpoint(DELTA_SENTINEL)
ch2.replay_writes(
[
("t0", "messages", HumanMessage(content="original", id="h1")),
("t1", "messages", HumanMessage(content="updated", id="h1")),
]
)
assert len(ch2.get()) == 1
assert ch2.get()[0].content == "updated"
def test_delta_channel_checkpoint_returns_sentinel() -> None:
"""checkpoint() always returns DELTA_SENTINEL regardless of state."""
ch = DeltaChannel(_messages_delta_reducer, list).from_checkpoint(MISSING)
assert ch.checkpoint() is DELTA_SENTINEL
ch.update([HumanMessage(content="hi", id="h1")])
assert ch.checkpoint() is DELTA_SENTINEL
# ---------------------------------------------------------------------------
# DeltaChannel — snapshot frequency
# ---------------------------------------------------------------------------
def test_delta_channel_snapshot_step_based() -> None:
"""Snapshots fire on every Nth step regardless of whether the channel was written.
With snapshot_frequency=N, every Nth pregel step produces a _DeltaSnapshot
blob even if the channel had no write that step (eager snapshot). This
bounds the ancestor walk to at most N steps on any read.
"""
class State(TypedDict):
messages: Annotated[
list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=5)
]
other: str
def node_a(state: State) -> dict:
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=f"a{i}", id=f"a{i}")]}
def node_b(state: State) -> dict:
return {"other": "y"}
g = StateGraph(State)
g.add_node("a", node_a)
g.add_node("b", node_b)
g.add_edge(START, "a")
g.add_edge("a", "b")
saver = InMemorySaver()
graph = g.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "t1"}}
for i in range(6):
graph.invoke(
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")], "other": ""},
config,
)
msg_blob_values = [
saver.serde.loads_typed((type_tag, blob))
for k, (type_tag, blob) in saver.blobs.items()
if k[2] == "messages" and type_tag == "msgpack" and blob
]
snapshots = [v for v in msg_blob_values if isinstance(v, _DeltaSnapshot)]
assert snapshots, "expected at least one _DeltaSnapshot blob for messages"
state = graph.get_state(config)
assert len(state.values["messages"]) == 12 # 6 human + 6 AI
def test_delta_channel_snapshot_fires_even_when_not_written() -> None:
"""Eager snapshot: _DeltaSnapshot stored at snapshot step even when the
channel had no write that step (node_b doesn't touch messages).
"""
class State(TypedDict):
messages: Annotated[
list, DeltaChannel(_messages_delta_reducer, snapshot_frequency=3)
]
tick: int
def writer(state: State) -> dict:
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=f"a{i}", id=f"a{i}")]}
def ticker(state: State) -> dict:
return {"tick": state["tick"] + 1}
g = StateGraph(State)
g.add_node("writer", writer)
g.add_node("ticker", ticker)
g.add_edge(START, "writer")
g.add_edge("writer", "ticker")
saver = InMemorySaver()
graph = g.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "t1"}}
for i in range(5):
graph.invoke(
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")], "tick": 0},
config,
)
msg_blobs = {
k: saver.serde.loads_typed((t, b))
for k, (t, b) in saver.blobs.items()
if k[2] == "messages" and t == "msgpack" and b
}
snapshots = {k: v for k, v in msg_blobs.items() if isinstance(v, _DeltaSnapshot)}
assert snapshots, (
"eager snapshots must fire even on steps where messages wasn't written"
)
state = graph.get_state(config)
assert len(state.values["messages"]) == 10 # 5 human + 5 AI
# ---------------------------------------------------------------------------
# DeltaChannel — end-to-end (InMemorySaver)
# ---------------------------------------------------------------------------
def test_delta_channel_inmemory_saver_assembles_writes() -> None:
"""InMemorySaver assembles writes from checkpoint_writes inside get_tuple."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer, list)]
n = {"v": 0}
def respond(state: State) -> dict:
n["v"] += 1
return {"messages": [AIMessage(content=f"ok{n['v']}", id=f"ai{n['v']}")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "t1"}}
graph.invoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
graph.invoke({"messages": [HumanMessage(content="bye", id="h2")]}, config)
saved = saver.get_tuple(config)
assert saved is not None
assert "messages" in saved.checkpoint["channel_values"]
assert saved.checkpoint["channel_values"]["messages"] is DELTA_SENTINEL
state = graph.get_state(config)
assert len(state.values["messages"]) == 4 # 2 human + 2 AI
# ---------------------------------------------------------------------------
# DeltaChannel — dict reducer
# ---------------------------------------------------------------------------
def _delta_channel_with_type(op, typ):
"""Build a DeltaChannel with an explicit type via the Annotated injection path."""
return _get_channel("_test", Annotated[typ, DeltaChannel(op)])
def test_delta_channel_dict_reducer_fresh_channel() -> None:
"""DeltaChannel with a dict reducer starts as empty dict on MISSING checkpoint."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
assert ch.is_available()
assert ch.get() == {}
def test_delta_channel_dict_reducer_basic_updates() -> None:
"""DeltaChannel with a dict reducer accumulates key/value pairs across steps."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
ch.update([{"a": 1}])
d1 = ch.checkpoint()
assert d1 is DELTA_SENTINEL
ch.update([{"b": 2}])
d2 = ch.checkpoint()
assert d2 is DELTA_SENTINEL
assert ch.get() == {"a": 1, "b": 2}
def test_delta_channel_dict_reducer_writes_reconstruction() -> None:
"""replay_writes on a fresh channel replays through a dict merge reducer."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
spec = _delta_channel_with_type(merge_dicts, dict)
ch = spec.from_checkpoint(DELTA_SENTINEL)
ch.replay_writes(
[
("t0", "files", {"a": 1}),
("t1", "files", {"b": 2}),
("t2", "files", {"c": 3}),
]
)
assert ch.get() == {"a": 1, "b": 2, "c": 3}
def test_delta_channel_dict_reducer_with_deletions() -> None:
"""Dict reducer that treats None values as deletions works end-to-end."""
def merge_files(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
for k, v in w.items():
if v is None:
result.pop(k, None)
else:
result[k] = v
return result
ch = _delta_channel_with_type(merge_files, dict).from_checkpoint(MISSING)
ch.update([{"file1.py": "content1", "file2.py": "content2"}])
ch.update([{"file1.py": None, "file3.py": "content3"}])
assert ch.get() == {"file2.py": "content2", "file3.py": "content3"}
spec = _delta_channel_with_type(merge_files, dict)
ch2 = spec.from_checkpoint(DELTA_SENTINEL)
ch2.replay_writes(
[
("t0", "files", {"file1.py": "content1", "file2.py": "content2"}),
("t1", "files", {"file1.py": None, "file3.py": "content3"}),
]
)
assert ch2.get() == {"file2.py": "content2", "file3.py": "content3"}
def test_delta_channel_dict_reducer_overwrite_in_update() -> None:
"""Overwrite(dict) in update() must preserve dict shape, not coerce to list."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
ch = _delta_channel_with_type(merge_dicts, dict).from_checkpoint(MISSING)
ch.update([{"a": 1}])
ch.update([Overwrite({"b": 2, "c": 3})])
assert ch.get() == {"b": 2, "c": 3}
def test_delta_channel_dict_reducer_overwrite_in_writes_replay() -> None:
"""Overwrite(dict) embedded in replayed writes must reconstruct as dict."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
spec = _delta_channel_with_type(merge_dicts, dict)
ch = spec.from_checkpoint(DELTA_SENTINEL)
ch.replay_writes(
[
("t0", "files", {"a": 1}),
("t1", "files", Overwrite({"x": 10, "y": 20})),
("t2", "files", {"z": 30}),
]
)
assert ch.get() == {"x": 10, "y": 20, "z": 30}
def test_delta_channel_dict_reducer_with_notrequired_annotation() -> None:
"""DeltaChannel infers dict type through `Annotated[NotRequired[dict[...]], ch]`."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
annotation = Annotated[NotRequired[dict[str, int]], DeltaChannel(merge_dicts)]
ch = _get_channel("files", annotation).from_checkpoint(MISSING)
assert ch.get() == {}
ch.update([{"a": 1}])
ch.update([{"b": 2}])
assert ch.get() == {"a": 1, "b": 2}
def test_delta_channel_dict_reducer_end_to_end_filesystem() -> None:
"""End-to-end: graph with dict-reducer (filesystem-style) channel wrapped in DeltaChannel."""
def merge_files(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
for k, v in w.items():
if v is None:
result.pop(k, None)
else:
result[k] = v
return result
class State(TypedDict):
files: Annotated[dict[str, str], DeltaChannel(merge_files)]
turn = {"v": 0}
def write_file(state: State) -> dict:
turn["v"] += 1
n = turn["v"]
return {"files": {f"/doc_{n}.txt": f"content for turn {n}"}}
builder = StateGraph(State)
builder.add_node("write_file", write_file)
builder.add_edge(START, "write_file")
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "fs"}}
for _ in range(3):
graph.invoke({"files": {}}, config)
saved = saver.get_tuple(config)
assert saved is not None
assert saved.checkpoint["channel_values"]["files"] is DELTA_SENTINEL
state = graph.get_state(config)
assert state.values["files"] == {
"/doc_1.txt": "content for turn 1",
"/doc_2.txt": "content for turn 2",
"/doc_3.txt": "content for turn 3",
}
def delete_file(state: State) -> dict:
return {"files": {"/doc_1.txt": None}}
builder2 = StateGraph(State)
builder2.add_node("write_file", write_file)
builder2.add_node("delete_file", delete_file)
builder2.add_edge(START, "write_file")
builder2.add_edge("write_file", "delete_file")
turn["v"] = 0
saver2 = InMemorySaver()
graph2 = builder2.compile(checkpointer=saver2)
config2 = {"configurable": {"thread_id": "fs2"}}
graph2.invoke({"files": {}}, config2)
state2 = graph2.get_state(config2)
assert state2.values["files"] == {}
def test_delta_channel_dict_reducer_backwards_compat() -> None:
"""A pre-DeltaChannel dict checkpoint must load as a dict, not be listified."""
def merge_dicts(state: dict, writes: list) -> dict:
result = dict(state)
for w in writes:
result.update(w)
return result
spec = _delta_channel_with_type(merge_dicts, dict)
old_value = {"a": 1, "b": 2}
ch = spec.from_checkpoint(old_value)
assert ch.get() == {"a": 1, "b": 2}
# ---------------------------------------------------------------------------
# DeltaChannel — seed / pre-delta migration
# ---------------------------------------------------------------------------
def test_delta_channel_from_checkpoint_honors_seed() -> None:
"""A non-sentinel value to from_checkpoint is used as the pre-delta seed.
Guards the pre-delta migration path: when the saver's ancestor walk hits
a pre-DeltaChannel blob it passes it as `seed` so replay reconstructs
the post-migration state correctly rather than replaying from empty.
"""
spec = DeltaChannel(_messages_delta_reducer, list)
seed = [HumanMessage(content="pre-delta", id="p1")]
ch = spec.from_checkpoint(seed)
ch.replay_writes(
[
("t0", "messages", AIMessage(content="delta-1", id="d1")),
("t1", "messages", HumanMessage(content="delta-2", id="d2")),
]
)
msgs = ch.get()
assert [m.content for m in msgs] == ["pre-delta", "delta-1", "delta-2"]
def test_delta_channel_from_checkpoint_seed_without_writes() -> None:
"""Reconstruction at a pre-delta ancestor with no newer deltas returns
just the seed the saver's terminator fired immediately."""
spec = DeltaChannel(_messages_delta_reducer, list)
seed = [HumanMessage(content="only-snap", id="s1")]
ch = spec.from_checkpoint(seed)
ch.replay_writes([])
assert ch.get() == seed
def test_delta_channel_from_checkpoint_seed_none_is_distinct_from_sentinel() -> None:
"""`seed=None` must start replay from None, not from an empty channel.
The DELTA_SENTINEL / MISSING sentinels mean 'no seed'; passing `None`
explicitly should feed None to the reducer as the left operand.
"""
def replace(state, writes):
return writes[-1] if writes else state
spec = DeltaChannel(replace, list)
ch = spec.from_checkpoint(None)
ch.replay_writes([("t0", "x", "after")])
assert ch.get() == "after"
@@ -0,0 +1,478 @@
"""Benchmark: DeltaChannel snapshot_frequency — storage vs. read-depth tradeoff.
Run directly: python tests/test_delta_channel_benchmark.py
Run via pytest: pytest tests/test_delta_channel_benchmark.py -s
Part 1 baseline (original): DeltaChannel(inf) vs add_messages (BinOp).
Part 2 snapshot_frequency sweep: shows the storage/read-latency tradeoff
across frequencies [1, 5, 10, 50, inf] at scale.
Key insight:
snapshot_frequency=inf O(N) storage, O(N) read depth (pure delta)
snapshot_frequency=N O(/N) storage, O(N) read depth bounded by freq
snapshot_frequency=1 O() storage, O(1) read depth (full snapshot)
"""
from __future__ import annotations
import contextlib
import math
import os
import sys
import time
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import TypedDict
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, StateGraph
from langgraph.graph.message import _messages_delta_reducer, add_messages
try:
from langgraph.checkpoint.postgres import PostgresSaver
_POSTGRES_AVAILABLE = True
_POSTGRES_URI = os.environ.get(
"LANGGRAPH_BENCH_POSTGRES_URI",
"postgres://postgres@localhost:5432/postgres?sslmode=disable",
)
except ImportError:
_POSTGRES_AVAILABLE = False
# ---------------------------------------------------------------------------
# Realistic message payload (~100 tokens / ~400 chars each)
# ---------------------------------------------------------------------------
_HUMAN_TEMPLATE = (
"I need help understanding the implications of {topic} on our system architecture. "
"Specifically, I'm concerned about how this interacts with our existing {concern} "
"and whether we need to refactor the {component} layer before proceeding. "
"We've had prior incidents in this area and want to be deliberate. "
"What should we prioritize first, and are there known failure modes we should design around from the start?"
)
_AI_TEMPLATE = (
"Great question about {topic}. The key insight here is that {concern} introduces "
"a subtle ordering dependency that most teams overlook until they hit it in production. "
"For your {component} layer specifically, I'd recommend starting with a careful audit "
"of the interface boundaries before making any structural changes. This will give you "
"a clear picture of the blast radius and let you sequence the migration safely."
)
_TOPICS = [
"distributed tracing",
"eventual consistency",
"schema migration",
"backpressure handling",
"idempotency guarantees",
"cache invalidation",
"connection pooling",
"rate limiting",
"circuit breaking",
"observability pipelines",
]
_CONCERNS = [
"concurrency model",
"retry semantics",
"state management",
"error propagation",
"latency budget",
]
_COMPONENTS = [
"persistence",
"routing",
"ingestion",
"aggregation",
"serialization",
]
def _human_content(i: int) -> str:
return _HUMAN_TEMPLATE.format(
topic=_TOPICS[i % len(_TOPICS)],
concern=_CONCERNS[i % len(_CONCERNS)],
component=_COMPONENTS[i % len(_COMPONENTS)],
)
def _ai_content(i: int) -> str:
return _AI_TEMPLATE.format(
topic=_TOPICS[i % len(_TOPICS)],
concern=_CONCERNS[i % len(_CONCERNS)],
component=_COMPONENTS[i % len(_COMPONENTS)],
)
# ---------------------------------------------------------------------------
# State definitions
# ---------------------------------------------------------------------------
class BinaryState(TypedDict):
messages: Annotated[list, add_messages]
class DeltaState(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def _make_delta_state(snapshot_frequency: int | float) -> type:
"""Create a TypedDict with DeltaChannel at the given snapshot_frequency."""
channel = DeltaChannel(
_messages_delta_reducer, snapshot_frequency=snapshot_frequency
)
# Use the functional TypedDict form so the Annotated type is stored as an
# already-evaluated object rather than a forward-reference string (which
# would fail when get_type_hints tries to resolve 'snapshot_frequency').
return TypedDict( # type: ignore[return-value]
f"DeltaState_freq{snapshot_frequency}",
{"messages": Annotated[list, channel]},
)
# ---------------------------------------------------------------------------
# Graph factory
# ---------------------------------------------------------------------------
def _make_graph(state_cls: type, checkpointer: Any = None) -> Any:
def human_node(state: Any) -> dict:
return {}
def ai_node(state: Any) -> dict:
i = len(state["messages"]) // 2
return {"messages": [AIMessage(content=_ai_content(i), id=f"a{i}")]}
g = StateGraph(state_cls)
g.add_node("human", human_node)
g.add_node("ai", ai_node)
g.add_edge("human", "ai")
g.add_edge("ai", END)
g.set_entry_point("human")
return g.compile(checkpointer=checkpointer or MemorySaver())
# ---------------------------------------------------------------------------
# Measurement helpers
# ---------------------------------------------------------------------------
def _total_blob_bytes(saver: MemorySaver) -> int:
total = 0
for (_, _, _, _), (type_tag, blob) in saver.blobs.items():
if blob is not None:
total += len(blob)
return total
def _run_turns(
n_turns: int,
state_cls: type,
checkpointer: Any = None,
) -> tuple[float, float, int]:
"""Run n_turns conversation turns.
Returns (write_elapsed_s, read_elapsed_s, total_blob_bytes).
Read latency is the average of 5 get_state calls after the full history
is built forces state rehydration including ancestor replay if needed.
"""
graph = _make_graph(state_cls, checkpointer)
config = {"configurable": {"thread_id": "bench"}}
t0 = time.perf_counter()
for i in range(n_turns):
graph.invoke(
{"messages": [HumanMessage(content=_human_content(i), id=f"h{i}")]},
config,
)
write_elapsed = time.perf_counter() - t0
t1 = time.perf_counter()
for _ in range(5):
graph.get_state(config)
read_elapsed = (time.perf_counter() - t1) / 5
blob_bytes = (
_total_blob_bytes(graph.checkpointer)
if isinstance(graph.checkpointer, MemorySaver)
else -1
)
return write_elapsed, read_elapsed, blob_bytes
def _fmt_bytes(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f} MB"
if n >= 1_000:
return f"{n / 1_000:.1f} KB"
return f"{n} B"
def _approx_tokens(n_turns: int) -> str:
tokens = n_turns * 200
if tokens >= 1_000_000:
return f"~{tokens / 1_000_000:.1f}M tok"
if tokens >= 1_000:
return f"~{tokens / 1_000:.0f}K tok"
return f"~{tokens} tok"
# ---------------------------------------------------------------------------
# Checkpointer factories
# ---------------------------------------------------------------------------
@contextlib.contextmanager
def _pg_saver(thread_id: str = "bench"):
"""Context manager that yields a fresh PostgresSaver and cleans up after."""
with PostgresSaver.from_conn_string(_POSTGRES_URI) as saver:
saver.setup()
with saver._cursor() as cur:
for tbl in ("checkpoints", "checkpoint_blobs", "checkpoint_writes"):
cur.execute(f"DELETE FROM {tbl} WHERE thread_id = %s", (thread_id,))
yield saver
with saver._cursor() as cur:
for tbl in ("checkpoints", "checkpoint_blobs", "checkpoint_writes"):
cur.execute(f"DELETE FROM {tbl} WHERE thread_id = %s", (thread_id,))
def _checkpointers() -> list[tuple[str, Any]]:
"""Return (label, saver_or_None) pairs for available checkpointers."""
result: list[tuple[str, Any]] = [("InMemory", None)]
if _POSTGRES_AVAILABLE:
try:
import psycopg
psycopg.connect(_POSTGRES_URI).close()
result.append(("Postgres", "postgres"))
except Exception:
pass
return result
# ---------------------------------------------------------------------------
# Part 1: baseline DeltaChannel(inf) vs add_messages
# ---------------------------------------------------------------------------
BASELINE_TURN_COUNTS = [10, 25, 50, 100, 500]
DELTA_ONLY_TURN_COUNTS = [1000]
def _run_baseline_for_checkpointer(cp_label: str, cp_hint: Any) -> None:
W = 72
def _make_saver():
if cp_hint is None:
return contextlib.nullcontext(None)
return _pg_saver()
rows: list[tuple[int, Any, Any, Any, Any, Any, Any]] = []
for turns in BASELINE_TURN_COUNTS:
with _make_saver() as saver:
b_wt, b_rt, b_bytes = _run_turns(turns, BinaryState, saver)
with _make_saver() as saver:
d_wt, d_rt, d_bytes = _run_turns(turns, DeltaState, saver)
rows.append((turns, b_bytes, d_bytes, b_rt, d_rt, b_wt, d_wt))
for turns in DELTA_ONLY_TURN_COUNTS:
with _make_saver() as saver:
d_wt, d_rt, d_bytes = _run_turns(turns, DeltaState, saver)
rows.append((turns, None, d_bytes, None, d_rt, None, d_wt))
def _bytes_or_na(v: Any) -> str:
if v is None or v < 0:
return "n/a"
return _fmt_bytes(v)
def _ms_or_na(v: Any) -> str:
return "n/a" if v is None else f"{v * 1000:.1f}ms"
print(f"\n [{cp_label}] Storage (blob bytes)")
print(
f" {'turns':>6} {'ctx':>10} {'add_msgs':>12} {'delta(inf)':>12} {'savings':>8}"
)
print(" " + "-" * (W - 2))
for turns, b_bytes, d_bytes, b_rt, d_rt, b_wt, d_wt in rows:
if b_bytes is None or b_bytes < 0 or d_bytes is None or d_bytes < 0:
ratio_str = "n/a"
else:
ratio = b_bytes / d_bytes if d_bytes else float("inf")
ratio_str = f"{ratio:.0f}x"
print(
f" {turns:>6} {_approx_tokens(turns):>10} "
f"{_bytes_or_na(b_bytes):>12} {_bytes_or_na(d_bytes):>12} {ratio_str:>8}"
)
print(f"\n [{cp_label}] Read latency (avg of 5 get_state calls)")
print(f" {'turns':>6} {'ctx':>10} {'add_msgs':>12} {'delta(inf)':>12}")
print(" " + "-" * (W - 2))
for turns, b_bytes, d_bytes, b_rt, d_rt, b_wt, d_wt in rows:
print(
f" {turns:>6} {_approx_tokens(turns):>10} "
f"{_ms_or_na(b_rt):>12} {_ms_or_na(d_rt):>12}"
)
def run_baseline_benchmark() -> None:
print()
print("Part 1 — DeltaChannel(inf) vs add_messages: storage & latency")
print("=" * 72)
for cp_label, cp_hint in _checkpointers():
_run_baseline_for_checkpointer(cp_label, cp_hint)
print()
# ---------------------------------------------------------------------------
# Part 2: snapshot_frequency sweep
# ---------------------------------------------------------------------------
# Frequencies to test. 1 = always snapshot (like BinOp), inf = pure delta.
SNAPSHOT_FREQUENCIES: list[int | float] = [1, 5, 10, 50, math.inf]
# Turn counts for the sweep — high enough to show storage divergence.
SWEEP_TURN_COUNTS = [50, 100, 500]
def _freq_label(freq: int | float) -> str:
if freq == math.inf:
return "inf"
return str(int(freq))
def _run_sweep_for_checkpointer(cp_label: str, cp_hint: Any) -> None:
def _make_saver():
if cp_hint is None:
return contextlib.nullcontext(None)
return _pg_saver()
# Collect results: {turns: {freq_label: (write_s, read_s, bytes)}}
results: dict[int, dict[str, tuple[float, float, int]]] = {}
for turns in SWEEP_TURN_COUNTS:
results[turns] = {}
for freq in SNAPSHOT_FREQUENCIES:
state_cls = _make_delta_state(freq)
with _make_saver() as saver:
wt, rt, bb = _run_turns(turns, state_cls, saver)
results[turns][_freq_label(freq)] = (wt, rt, bb)
freq_labels = [_freq_label(f) for f in SNAPSHOT_FREQUENCIES]
col_w = 12
header = f" {'turns':>6} {'ctx':>10}" + "".join(
f" {f'freq={freq_label}':>{col_w}}" for freq_label in freq_labels
)
print(f"\n [{cp_label}] Storage (blob bytes) — lower is better")
print(header)
print(" " + "-" * (len(header) - 2))
for turns in SWEEP_TURN_COUNTS:
row = f" {turns:>6} {_approx_tokens(turns):>10}"
for label in freq_labels:
_, _, bb = results[turns][label]
row += f" {_fmt_bytes(bb) if bb >= 0 else 'n/a':>{col_w}}"
print(row)
print(f"\n [{cp_label}] Read latency (avg of 5 get_state) — lower is better")
print(header)
print(" " + "-" * (len(header) - 2))
for turns in SWEEP_TURN_COUNTS:
row = f" {turns:>6} {_approx_tokens(turns):>10}"
for label in freq_labels:
_, rt, _ = results[turns][label]
row += f" {f'{rt * 1000:.1f}ms':>{col_w}}"
print(row)
print(
f"\n [{cp_label}] Per-invoke write latency (total / turns) — lower is better"
)
print(header)
print(" " + "-" * (len(header) - 2))
for turns in SWEEP_TURN_COUNTS:
row = f" {turns:>6} {_approx_tokens(turns):>10}"
for label in freq_labels:
wt, _, _ = results[turns][label]
row += f" {f'{(wt / turns) * 1000:.1f}ms':>{col_w}}"
print(row)
def run_snapshot_freq_benchmark() -> None:
print()
print("Part 2 — DeltaChannel snapshot_frequency sweep")
print("Lower freq → fewer snapshots → less storage but deeper read replay")
print("=" * 80)
for cp_label, cp_hint in _checkpointers():
_run_sweep_for_checkpointer(cp_label, cp_hint)
print()
print("Legend:")
print(
" freq=1 snapshot every write (full blob always — same as add_messages / BinOp)"
)
print(" freq=N snapshot every N writes; read walks at most N ancestor writes")
print(" freq=inf pure delta; read walks entire ancestor chain")
print()
# ---------------------------------------------------------------------------
# Pytest entry points
# ---------------------------------------------------------------------------
@pytest.mark.skip(
reason="slow benchmark — run manually with: python tests/test_delta_channel_benchmark.py"
)
def test_delta_channel_baseline_benchmark(capsys: Any) -> None:
"""DeltaChannel(inf) uses less storage than add_messages at scale."""
with capsys.disabled():
run_baseline_benchmark()
for turns in [25, 50]:
_, _, b_bytes = _run_turns(turns, BinaryState)
_, _, d_bytes = _run_turns(turns, DeltaState)
assert d_bytes < b_bytes, (
f"DeltaChannel should use less storage at {turns} turns, "
f"got delta={d_bytes} binary={b_bytes}"
)
@pytest.mark.skip(
reason="slow benchmark — run manually with: python tests/test_delta_channel_benchmark.py"
)
def test_snapshot_freq_benchmark(capsys: Any) -> None:
"""snapshot_frequency trades storage for bounded read depth."""
with capsys.disabled():
run_snapshot_freq_benchmark()
# Correctness: results at all frequencies should agree on final state.
n_turns = 20
states: dict[str, list] = {}
for freq in SNAPSHOT_FREQUENCIES:
state_cls = _make_delta_state(freq)
graph = _make_graph(state_cls)
config = {"configurable": {"thread_id": "correctness"}}
for i in range(n_turns):
graph.invoke(
{"messages": [HumanMessage(content=_human_content(i), id=f"h{i}")]},
config,
)
state = graph.get_state(config)
states[_freq_label(freq)] = [m.id for m in state.values["messages"]]
ref = states["inf"]
for label, msg_ids in states.items():
assert msg_ids == ref, (
f"freq={label} produced different message IDs than freq=inf"
)
# ---------------------------------------------------------------------------
# Script entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
run_baseline_benchmark()
run_snapshot_freq_benchmark()
sys.exit(0)
@@ -0,0 +1,613 @@
"""Tests for the BinaryOperatorAggregate -> DeltaChannel migration path.
A thread written under `BinaryOperatorAggregate(...)` must keep working
after its annotation is swapped to `DeltaChannel(...)` on the same
checkpointer pre-migration state visible at each *settled* ancestor
checkpoint is preserved, and post-migration writes fold on top through
the reducer.
Mechanism under test: the saver's `_get_channel_writes_history(config,
channel)` walks the parent chain; when it encounters an ancestor whose
`channel_values[channel]` is a real value (not `DELTA_SENTINEL`), it
returns that as the `seed`. `DeltaChannel.from_checkpoint(seed)` uses
it as the base value, and `replay_writes(writes)` folds on-path deltas.
Scenarios covered:
1. **Basic migration (sync + async)**: build pre-migration state with
`BinaryOperatorAggregate`, swap the annotation to `DeltaChannel` on
the same checkpointer, and verify that every settled pre-migration
super-step boundary (`next=('__start__',)`) round-trips exactly
under the delta-channel view.
2. **Time travel into a pre-migration checkpoint** after migration
`graph.get_state(pre_migration_config)` at a settled ancestor
returns the same state as under the binop channel.
3. **Continuing a migrated thread**: driving one more super-step after
migration produces a state that includes the pre-migration settled
prefix plus the new delta write proving `from_checkpoint(seed)` +
`replay_writes` correctly fold post-migration deltas onto the
pre-migration seed.
4. **Base-saver fallback path**: a third-party-style subclass that
removes the optimized `InMemorySaver` override and falls back to
`BaseCheckpointSaver._get_channel_writes_history` must produce the
same result as the optimized path.
5. **Channel-type isolation across threads**: two threads on the same
checkpointer under the delta-channel graph one freshly-started,
one migrated from pre-migration state don't cross-contaminate.
The parent-chain walk is scoped to the thread.
TODO: add postgres variants in the existing `libs/checkpoint-postgres`
test files (different fixture setup; not this file).
"""
from __future__ import annotations
import operator
from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import _messages_delta_reducer, add_messages
pytestmark = pytest.mark.anyio
# ---------------------------------------------------------------------------
# Graph factories
#
# A minimal reducer (`operator.add` on lists of str) with a noop node keeps
# state change localized to the HumanMessage-like payload passed through
# `invoke`. That isolates the pre/post-migration parity assertions to
# channel-hydration semantics.
# ---------------------------------------------------------------------------
def _noop(_state: Any) -> dict:
return {}
def _list_concat(state: list, writes: list) -> list:
result = list(state)
for w in writes:
result.extend(w if isinstance(w, list) else [w])
return result
def _binop_graph(checkpointer: Any) -> Any:
class BinopState(TypedDict):
items: Annotated[list, BinaryOperatorAggregate(list, operator.add)]
return (
StateGraph(BinopState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def _delta_graph(checkpointer: Any) -> Any:
class DeltaState(TypedDict):
items: Annotated[list, DeltaChannel(_list_concat)]
return (
StateGraph(DeltaState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def _drive(graph: Any, config: dict, tag: str, n: int) -> None:
for i in range(n):
graph.invoke({"items": [f"{tag}{i}"]}, config)
async def _adrive(graph: Any, config: dict, tag: str, n: int) -> None:
for i in range(n):
await graph.ainvoke({"items": [f"{tag}{i}"]}, config)
def _settled_boundaries(history: list) -> list[tuple[dict, list]]:
"""Return `[(config, items), ...]` for every checkpoint in `history`
whose `next == ('__start__',)` the stable boundaries between invokes.
"""
return [
(s.config, list(s.values.get("items", [])))
for s in history
if s.next == ("__start__",)
]
# ---------------------------------------------------------------------------
# 1. Basic migration (sync + async)
# ---------------------------------------------------------------------------
def test_basic_migration_preserves_pre_migration_state() -> None:
"""Build state under `BinaryOperatorAggregate`, migrate to
`DeltaChannel` on the same checkpointer, and verify that every
settled pre-migration super-step boundary round-trips exactly.
Settled boundaries (`next=('__start__',)`) are the stable hydration
targets for the migration path: writes that produced the NEXT
super-step are kept as `pending_writes` on the ancestor, so walking
from a descendant finds the ancestor's blob as the seed and
reconstructs the correct state.
"""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "basic-sync"}}
# Pre-migration: accumulate items across 3 invokes.
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 3)
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
assert len(pre_boundaries) >= 2, "expected multiple settled boundaries"
# Migrate: swap the annotation on the same checkpointer.
delta = _delta_graph(checkpointer)
for cfg, items in pre_boundaries:
snap = delta.get_state(cfg)
assert list(snap.values.get("items", [])) == items, (
f"snapshot mismatch at {cfg['configurable']['checkpoint_id']}: "
f"expected {items}, got {snap.values.get('items', [])}"
)
async def test_basic_migration_preserves_pre_migration_state_async() -> None:
"""Async variant of the basic migration scenario."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "basic-async"}}
binop = _binop_graph(checkpointer)
await _adrive(binop, config, "u", 3)
pre_history = [s async for s in binop.aget_state_history(config)]
pre_boundaries = _settled_boundaries(pre_history)
assert len(pre_boundaries) >= 2
delta = _delta_graph(checkpointer)
for cfg, items in pre_boundaries:
snap = await delta.aget_state(cfg)
assert list(snap.values.get("items", [])) == items, (
f"async snapshot mismatch at {cfg['configurable']['checkpoint_id']}"
)
# ---------------------------------------------------------------------------
# 2. Time travel into a pre-migration checkpoint after migration
# ---------------------------------------------------------------------------
def test_time_travel_into_pre_migration_checkpoint() -> None:
"""After migration, `graph.get_state(pre_migration_config)` at a
settled ancestor returns the state as stored at that point."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "time-travel"}}
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 3)
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
assert pre_boundaries, "no settled ancestors to time-travel to"
delta = _delta_graph(checkpointer)
# Pick the oldest non-empty boundary — a long distance to walk back.
non_empty = [(cfg, items) for cfg, items in pre_boundaries if items]
assert non_empty, "expected at least one non-empty boundary"
target_cfg, expected_items = non_empty[-1]
snap = delta.get_state(target_cfg)
assert list(snap.values.get("items", [])) == expected_items
# ---------------------------------------------------------------------------
# 3. Continuing a migrated thread: deltas fold onto pre-migration seed
# ---------------------------------------------------------------------------
def test_continuing_migrated_thread_folds_deltas_on_seed() -> None:
"""Resume a pre-migration settled ancestor via `invoke(None, cfg)`
under the delta-channel graph. Since the pre-migration checkpoint
has an existing `pending_writes` entry (the input for the NEXT
super-step), re-running from that ancestor reproduces the same
post-ancestor state as the original binop run.
This proves the seed-terminator + write-replay pipeline works
end-to-end across the migration boundary.
"""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "continue"}}
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 2)
# Pick the oldest settled boundary with non-empty state.
pre_boundaries = _settled_boundaries(list(binop.get_state_history(config)))
target_cfg, seed_items = next(
(cfg, items) for cfg, items in reversed(pre_boundaries) if items
)
assert seed_items, "need a non-empty seed boundary"
# Migrate and resume from the pre-migration ancestor. `invoke(None,
# cfg)` replays the pending writes staged at `cfg` under the new
# channel; the reducer folds those deltas onto the seed.
delta = _delta_graph(checkpointer)
result = delta.invoke(None, target_cfg)
# The resumed state must include the pre-migration seed items in order.
result_items = list(result.get("items", []))
for idx, prefix_item in enumerate(seed_items):
assert result_items[idx] == prefix_item, (
f"pre-migration seed item at {idx} not preserved: "
f"got {result_items[: idx + 1]}, expected {seed_items}"
)
# ---------------------------------------------------------------------------
# 4. Base-saver fallback path
# ---------------------------------------------------------------------------
class _ThirdPartyStyleSaver(InMemorySaver):
"""Simulates a third-party saver that inherits the reference
`_get_channel_writes_history` implementation from
`BaseCheckpointSaver` rather than overriding it.
We rebind the two methods to the base-class versions (via MRO) so
the fallback path is exercised even though the storage layer is
still the in-memory one.
"""
# MRO: [_ThirdPartyStyleSaver, InMemorySaver, BaseCheckpointSaver, ...]
_get_channel_writes_history = ( # type: ignore[assignment]
InMemorySaver.__mro__[1]._get_channel_writes_history # type: ignore[attr-defined]
)
_aget_channel_writes_history = ( # type: ignore[assignment]
InMemorySaver.__mro__[1]._aget_channel_writes_history # type: ignore[attr-defined]
)
def test_base_saver_fallback_matches_optimized_override() -> None:
"""The reference `BaseCheckpointSaver` implementation must produce
the same migration behavior as the optimized `InMemorySaver`
override. We drive the same migration scenario through both savers
and assert per-snapshot parity in the delta-channel view."""
# Fast path: optimized InMemorySaver override.
fast_saver = InMemorySaver()
fast_config = {"configurable": {"thread_id": "fast"}}
fast_binop = _binop_graph(fast_saver)
_drive(fast_binop, fast_config, "u", 3)
fast_delta = _delta_graph(fast_saver)
fast_history = [
(s.next, list(s.values.get("items", [])))
for s in fast_delta.get_state_history(fast_config)
]
# Slow path: base-class fallback.
slow_saver = _ThirdPartyStyleSaver()
slow_config = {"configurable": {"thread_id": "slow"}}
slow_binop = _binop_graph(slow_saver)
_drive(slow_binop, slow_config, "u", 3)
slow_delta = _delta_graph(slow_saver)
slow_history = [
(s.next, list(s.values.get("items", [])))
for s in slow_delta.get_state_history(slow_config)
]
assert slow_history == fast_history, (
"base-saver fallback should match optimized-override behavior; "
f"fast={fast_history}, slow={slow_history}"
)
# ---------------------------------------------------------------------------
# 5. Thread isolation under mixed-generation storage
# ---------------------------------------------------------------------------
def test_delta_and_migrated_threads_do_not_cross_contaminate() -> None:
"""Two threads sharing a checkpointer — one migrated from
pre-migration state, one freshly-started under DeltaChannel must
maintain independent state. The parent-chain walk in
`_get_channel_writes_history` must be scoped to the target thread.
"""
checkpointer = InMemorySaver()
migrated_cfg = {"configurable": {"thread_id": "migrated"}}
fresh_cfg = {"configurable": {"thread_id": "fresh"}}
# Thread A: pre-migration build-up.
binop = _binop_graph(checkpointer)
_drive(binop, migrated_cfg, "m", 2)
# Thread B: fresh delta-channel run.
delta = _delta_graph(checkpointer)
_drive(delta, fresh_cfg, "f", 2)
# Thread A: migrate and confirm its state is anchored in its own
# thread's pre-migration history (tag 'm'), never mixing in tag 'f'.
migrated_boundaries = _settled_boundaries(
list(delta.get_state_history(migrated_cfg))
)
assert migrated_boundaries, "migrated thread has no settled boundaries"
for _, items in migrated_boundaries:
for it in items:
assert it.startswith("m"), (
f"migrated thread leaked item from other thread: {it}"
)
# Thread B: settled boundaries must only contain 'f' tags.
fresh_boundaries = _settled_boundaries(list(delta.get_state_history(fresh_cfg)))
assert fresh_boundaries, "fresh thread has no settled boundaries"
for _, items in fresh_boundaries:
for it in items:
assert it.startswith("f"), (
f"fresh thread leaked item from migrated thread: {it}"
)
# ---------------------------------------------------------------------------
# 6. Tip-of-pre-migration hydration: the latest checkpoint from a binop-run
# thread has a real accumulated value in its own `channel_values["items"]`.
# When hydrated under the delta-channel graph via `get_state(config)` with no
# `checkpoint_id`, the short-circuit must use that value directly instead of
# walking ancestors (which would skip the tip's own blob).
# ---------------------------------------------------------------------------
def test_tip_of_pre_migration_hydrates_directly() -> None:
"""`graph.get_state(config)` at the latest (pre-migration) checkpoint
returns the full accumulated list stored in that checkpoint's own
`channel_values`. The hydration must not walk ancestors past it."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "tip-sync"}}
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 3)
binop_tip = binop.get_state(config)
expected_items = list(binop_tip.values.get("items", []))
assert expected_items == ["u0", "u1", "u2"], (
f"sanity: pre-migration tip should accumulate all 3 items, got {expected_items}"
)
delta = _delta_graph(checkpointer)
snap = delta.get_state(config)
assert list(snap.values.get("items", [])) == expected_items, (
f"tip hydration mismatch: expected {expected_items}, "
f"got {snap.values.get('items', [])}"
)
async def test_tip_of_pre_migration_hydrates_directly_async() -> None:
"""Async variant of the tip-of-pre-migration hydration scenario."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "tip-async"}}
binop = _binop_graph(checkpointer)
await _adrive(binop, config, "u", 3)
binop_tip = await binop.aget_state(config)
expected_items = list(binop_tip.values.get("items", []))
assert expected_items == ["u0", "u1", "u2"]
delta = _delta_graph(checkpointer)
snap = await delta.aget_state(config)
assert list(snap.values.get("items", [])) == expected_items, (
f"async tip hydration mismatch: expected {expected_items}, "
f"got {snap.values.get('items', [])}"
)
# ---------------------------------------------------------------------------
# 7. `update_state` after migration writes a real value to the new
# checkpoint's `channel_values` (not a sentinel). Hydration must use it
# directly — the ancestor walk would skip this blob and return stale state.
# ---------------------------------------------------------------------------
def test_update_state_after_migration_uses_written_value() -> None:
"""After migrating and running at least one post-migration super-step
(so the thread's tip has a `DELTA_SENTINEL`), `update_state` writes a
concrete value to a new checkpoint's `channel_values`. `get_state`
must reflect that concrete value."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "update-state"}}
# Pre-migration: accumulate a little state.
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 2)
# Migrate and run one more super-step so the tip is a post-migration
# checkpoint with `DELTA_SENTINEL` in its own `channel_values`.
delta = _delta_graph(checkpointer)
delta.invoke({"items": ["post"]}, config)
# `update_state` writes a concrete value into a new checkpoint's blob
# via the reducer against the hydrated prior state.
delta.update_state(config, {"items": ["x", "y"]})
snap = delta.get_state(config)
updated_items = list(snap.values.get("items", []))
# Must include the "x","y" update; without the hydration fix, the
# update_state-written blob would be skipped in favor of an ancestor
# walk, and the update values would disappear.
assert "x" in updated_items and "y" in updated_items, (
f"update_state values missing from snapshot: {updated_items}"
)
# The "x","y" items should be folded onto the prior accumulated state,
# not stand alone. This verifies the update-written blob is used
# directly by `get_state` (no ancestor walk past it).
assert len(updated_items) >= 4, (
f"update_state snapshot should preserve pre-update state, got {updated_items}"
)
assert updated_items[-2:] == ["x", "y"], (
f"update_state deltas should be at the tail, got {updated_items}"
)
# ---------------------------------------------------------------------------
# 8. Fork from an `update_state` checkpoint: a new run branched off the
# update_state-produced checkpoint must see that checkpoint's concrete
# `channel_values` as its base, with new deltas folded on top.
# ---------------------------------------------------------------------------
def test_fork_from_update_state_checkpoint() -> None:
"""Branching a new run from the checkpoint produced by `update_state`
must use that checkpoint's concrete blob as the base. Additional
deltas from the forked run fold onto it through the reducer."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "fork"}}
# Pre-migration build-up, then migrate and add one post-migration step.
binop = _binop_graph(checkpointer)
_drive(binop, config, "u", 2)
delta = _delta_graph(checkpointer)
delta.invoke({"items": ["post"]}, config)
# Apply `update_state` and capture the returned config (references
# the new checkpoint produced by the update).
update_cfg = delta.update_state(config, {"items": ["x", "y"]})
update_snap = delta.get_state(update_cfg)
base_items = list(update_snap.values.get("items", []))
assert "x" in base_items and "y" in base_items, (
f"update_state values missing from snapshot: {base_items}"
)
assert base_items[-2:] == ["x", "y"], (
f"sanity: update_state deltas should be at the tail, got {base_items}"
)
# Fork: invoke from the update_state checkpoint with a new delta.
forked = delta.invoke({"items": ["fork0"]}, update_cfg)
forked_items = list(forked.get("items", []))
# The fork must see the update_state-written blob as its base (not
# walk past it), and the new delta must fold on top of it.
assert forked_items[: len(base_items)] == base_items, (
f"fork lost update_state base: base={base_items}, forked={forked_items}"
)
assert forked_items[-1] == "fork0", f"fork delta not appended: {forked_items}"
# ---------------------------------------------------------------------------
# 9. Migration from `add_messages` → `DeltaChannel(_messages_delta_reducer)`
#
# `add_messages` is the primary real-world use case: it creates a
# BinaryOperatorAggregate with dedup-by-ID and RemoveMessage semantics.
# After swapping the annotation to DeltaChannel, pre-migration blobs
# (plain lists of Message objects) must be used directly as the seed.
# ---------------------------------------------------------------------------
def _add_messages_graph(checkpointer: Any) -> Any:
class MessagesState(TypedDict):
messages: Annotated[list, add_messages]
return (
StateGraph(MessagesState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def _delta_messages_graph(checkpointer: Any) -> Any:
class DeltaMessagesState(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
return (
StateGraph(DeltaMessagesState)
.add_node("noop", _noop)
.add_edge(START, "noop")
.add_edge("noop", END)
.compile(checkpointer=checkpointer)
)
def test_add_messages_to_delta_migration_preserves_message_history() -> None:
"""Migration from `add_messages` to `DeltaChannel(_messages_delta_reducer)`
preserves message ordering and IDs at both the tip and settled ancestor
boundaries.
The pre-migration blob is a plain list of Message objects; DeltaChannel
must use it directly as the seed without walking ancestors past it.
"""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "add-messages-migration"}}
pre_graph = _add_messages_graph(checkpointer)
pre_graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
pre_graph.invoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
pre_graph.invoke({"messages": [HumanMessage(content="thanks", id="h2")]}, config)
pre_tip = pre_graph.get_state(config)
assert [m.id for m in pre_tip.values["messages"]] == ["h1", "a1", "h2"]
delta_graph = _delta_messages_graph(checkpointer)
# Tip: latest checkpoint has a full list blob — must use it directly.
snap = delta_graph.get_state(config)
assert [m.id for m in snap.values["messages"]] == ["h1", "a1", "h2"], (
f"tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
)
# Settled ancestor boundaries must also match.
pre_settled = [
[m.id for m in s.values.get("messages", [])]
for s in pre_graph.get_state_history(config)
if s.next == ("__start__",)
]
delta_settled = [
[m.id for m in s.values.get("messages", [])]
for s in delta_graph.get_state_history(config)
if s.next == ("__start__",)
]
assert delta_settled == pre_settled, (
f"settled boundary mismatch after migration: "
f"pre={pre_settled}, delta={delta_settled}"
)
async def test_add_messages_to_delta_migration_preserves_message_history_async() -> (
None
):
"""Async variant of the add_messages migration test."""
checkpointer = InMemorySaver()
config = {"configurable": {"thread_id": "add-messages-migration-async"}}
pre_graph = _add_messages_graph(checkpointer)
await pre_graph.ainvoke(
{"messages": [HumanMessage(content="hello", id="h1")]}, config
)
await pre_graph.ainvoke({"messages": [AIMessage(content="hi", id="a1")]}, config)
delta_graph = _delta_messages_graph(checkpointer)
snap = await delta_graph.aget_state(config)
assert [m.id for m in snap.values["messages"]] == ["h1", "a1"], (
f"async tip hydration mismatch: got {[m.id for m in snap.values['messages']]}"
)
@@ -0,0 +1,344 @@
from __future__ import annotations
import sys
from typing import Any
import pytest
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.callbacks.manager import CallbackManager
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
from langgraph.callbacks import (
GraphCallbackHandler,
GraphInterruptEvent,
GraphResumeEvent,
)
from langgraph.graph import START, StateGraph
from langgraph.types import Command, Interrupt, interrupt
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
class _GraphEventHandler(GraphCallbackHandler):
def __init__(self) -> None:
self.interrupt_events: list[GraphInterruptEvent] = []
self.resume_events: list[GraphResumeEvent] = []
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
self.interrupt_events.append(event)
def on_resume(self, event: GraphResumeEvent) -> Any:
self.resume_events.append(event)
class _LangChainCustomEventHandler(BaseCallbackHandler):
run_inline = True
def __init__(self) -> None:
self.events: list[str] = []
def on_custom_event(self, name: str, data: Any, **kwargs: Any) -> Any:
self.events.append(name)
class _RaisingGraphEventHandler(GraphCallbackHandler):
def __init__(
self,
*,
raise_on_interrupt: bool = False,
raise_on_resume: bool = False,
raise_error: bool = False,
) -> None:
self.raise_on_interrupt = raise_on_interrupt
self.raise_on_resume = raise_on_resume
self.raise_error = raise_error
def on_interrupt(self, event: GraphInterruptEvent) -> Any:
if self.raise_on_interrupt:
raise ValueError("boom-interrupt")
def on_resume(self, event: GraphResumeEvent) -> Any:
if self.raise_on_resume:
raise ValueError("boom-resume")
class _AsyncRaisingGraphEventHandler(GraphCallbackHandler):
def __init__(
self,
*,
raise_on_interrupt: bool = False,
raise_on_resume: bool = False,
raise_error: bool = False,
) -> None:
self.raise_on_interrupt = raise_on_interrupt
self.raise_on_resume = raise_on_resume
self.raise_error = raise_error
async def on_interrupt(self, event: GraphInterruptEvent) -> Any:
if self.raise_on_interrupt:
raise ValueError("boom-interrupt")
async def on_resume(self, event: GraphResumeEvent) -> Any:
if self.raise_on_resume:
raise ValueError("boom-resume")
class _State(TypedDict):
answer: str | None
def _build_interrupt_graph() -> Any:
def ask(state: _State) -> _State:
answer = interrupt("Provide value")
return {"answer": answer}
builder = StateGraph(_State)
builder.add_node("ask", ask)
builder.add_edge(START, "ask")
return builder.compile(checkpointer=InMemorySaver())
def test_graph_callbacks_interrupt_and_resume_sync() -> None:
graph = _build_interrupt_graph()
handler = _GraphEventHandler()
langchain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-sync"},
"callbacks": [langchain_handler, handler],
}
first = graph.invoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(handler.interrupt_events) == 1
assert handler.interrupt_events[0].interrupts
assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt)
assert handler.interrupt_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
handler.resume_events.clear()
resumed = graph.invoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(handler.resume_events) == 1
assert handler.resume_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_graph_callbacks_interrupt_and_resume_async() -> None:
graph = _build_interrupt_graph()
handler = _GraphEventHandler()
langchain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-async"},
"callbacks": [langchain_handler, handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(handler.interrupt_events) == 1
assert handler.interrupt_events[0].interrupts
assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt)
assert handler.interrupt_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
handler.resume_events.clear()
resumed = await graph.ainvoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(handler.resume_events) == 1
assert handler.resume_events[0].checkpoint_ns == ()
assert langchain_handler.events == []
def test_graph_callbacks_continue_when_interrupt_handler_raises_sync() -> None:
graph = _build_interrupt_graph()
raising_handler = _RaisingGraphEventHandler(raise_on_interrupt=True)
recording_handler = _GraphEventHandler()
first = graph.invoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-sync-raises"},
"callbacks": [raising_handler, recording_handler],
},
)
assert "__interrupt__" in first
assert len(recording_handler.interrupt_events) == 1
def test_graph_callbacks_continue_when_resume_handler_raises_sync() -> None:
graph = _build_interrupt_graph()
raising_handler = _RaisingGraphEventHandler(raise_on_resume=True)
recording_handler = _GraphEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-sync-raises-resume"},
"callbacks": [raising_handler, recording_handler],
}
first = graph.invoke({"answer": None}, config)
assert "__interrupt__" in first
resumed = graph.invoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(recording_handler.resume_events) == 1
def test_graph_callbacks_raise_error_propagates_sync() -> None:
graph = _build_interrupt_graph()
raising_handler = _RaisingGraphEventHandler(
raise_on_interrupt=True,
raise_error=True,
)
with pytest.raises(ValueError, match="boom-interrupt"):
graph.invoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-sync-raise-error"},
"callbacks": [raising_handler],
},
)
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_graph_callbacks_continue_when_handler_raises_async() -> None:
graph = _build_interrupt_graph()
raising_interrupt_handler = _AsyncRaisingGraphEventHandler(raise_on_interrupt=True)
recording_handler = _GraphEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-async-raises-interrupt"},
"callbacks": [raising_interrupt_handler, recording_handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(recording_handler.interrupt_events) == 1
graph = _build_interrupt_graph()
raising_resume_handler = _AsyncRaisingGraphEventHandler(raise_on_resume=True)
recording_handler = _GraphEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-async-raises-resume"},
"callbacks": [raising_resume_handler, recording_handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
resumed = await graph.ainvoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(recording_handler.resume_events) == 1
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_graph_callbacks_raise_error_propagates_async() -> None:
graph = _build_interrupt_graph()
raising_handler = _AsyncRaisingGraphEventHandler(
raise_on_interrupt=True,
raise_error=True,
)
with pytest.raises(ValueError, match="boom-interrupt"):
await graph.ainvoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-async-raise-error"},
"callbacks": [raising_handler],
},
)
def test_graph_callbacks_accept_base_callback_manager() -> None:
graph = _build_interrupt_graph()
graph_handler = _GraphEventHandler()
custom_handler = _LangChainCustomEventHandler()
manager = CallbackManager.configure(inheritable_callbacks=[custom_handler])
manager.add_handler(graph_handler)
first = graph.invoke(
{"answer": None},
{
"configurable": {"thread_id": "graph-callback-base-manager"},
"callbacks": manager,
},
)
assert "__interrupt__" in first
assert len(graph_handler.interrupt_events) == 1
def test_non_graph_handler_via_add_handler_does_not_crash() -> None:
"""Non-GraphCallbackHandler added via add_handler should not raise.
Libraries like opentelemetry-instrumentation-langchain monkey-patch
BaseCallbackManager.__init__ and inject handlers via add_handler().
These handlers inherit from BaseCallbackHandler, not
GraphCallbackHandler. They must be silently accepted graph lifecycle
events will simply not be dispatched to them.
"""
from langgraph.callbacks import _GraphCallbackManager
manager = _GraphCallbackManager()
plain_handler = _LangChainCustomEventHandler()
manager.add_handler(plain_handler, inherit=True)
assert plain_handler in manager.handlers
def test_non_graph_handler_does_not_receive_lifecycle_events() -> None:
"""Non-GraphCallbackHandler added alongside a GraphCallbackHandler
should not interfere with lifecycle event dispatch."""
graph = _build_interrupt_graph()
graph_handler = _GraphEventHandler()
plain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-mixed-handlers"},
"callbacks": [plain_handler, graph_handler],
}
first = graph.invoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(graph_handler.interrupt_events) == 1
assert plain_handler.events == []
resumed = graph.invoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(graph_handler.resume_events) == 1
assert plain_handler.events == []
@pytest.mark.anyio
@NEEDS_CONTEXTVARS
async def test_non_graph_handler_does_not_receive_lifecycle_events_async() -> None:
"""Async variant: non-GraphCallbackHandler should not interfere."""
graph = _build_interrupt_graph()
graph_handler = _GraphEventHandler()
plain_handler = _LangChainCustomEventHandler()
config = {
"configurable": {"thread_id": "graph-callback-mixed-handlers-async"},
"callbacks": [plain_handler, graph_handler],
}
first = await graph.ainvoke({"answer": None}, config)
assert "__interrupt__" in first
assert len(graph_handler.interrupt_events) == 1
assert plain_handler.events == []
resumed = await graph.ainvoke(Command(resume="done"), config)
assert resumed == {"answer": "done"}
assert len(graph_handler.resume_events) == 1
assert plain_handler.events == []
-6
View File
@@ -1396,7 +1396,6 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1459,7 +1458,6 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1512,7 +1510,6 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6884,7 +6881,6 @@ def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6912,7 +6908,6 @@ def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "model_node"),
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
"checkpoint_ns": AnyStr("weather_graph:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6949,7 +6944,6 @@ def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1147,7 +1147,6 @@ async def test_prebuilt_tool_chat() -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1210,7 +1209,6 @@ async def test_prebuilt_tool_chat() -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -1263,7 +1261,6 @@ async def test_prebuilt_tool_chat() -> None:
"langgraph_path": (PULL, "agent"),
"langgraph_checkpoint_ns": AnyStr("agent:"),
"checkpoint_ns": AnyStr("agent:"),
"_type": "generic-fake-chat-model",
"ls_provider": "fakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -3981,7 +3978,6 @@ async def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -4009,7 +4005,6 @@ async def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "model_node"),
"langgraph_checkpoint_ns": AnyStr("weather_graph:"),
"checkpoint_ns": AnyStr("weather_graph:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -4046,7 +4041,6 @@ async def test_weather_subgraph(
"langgraph_path": ("__pregel_pull", "router_node"),
"langgraph_checkpoint_ns": AnyStr("router_node:"),
"checkpoint_ns": AnyStr("router_node:"),
"_type": "fake-messages-list-chat-model",
"ls_provider": "fakemessageslistchatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
+330 -19
View File
@@ -16,7 +16,7 @@ from typing import Annotated, Any, Literal, get_type_hints
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage
from langchain_core.runnables import (
RunnableConfig,
RunnableLambda,
@@ -25,6 +25,7 @@ from langchain_core.runnables import (
from langchain_core.runnables.graph import Edge
from langgraph.cache.base import BaseCache
from langgraph.checkpoint.base import (
DELTA_SENTINEL,
BaseCheckpointSaver,
Checkpoint,
CheckpointMetadata,
@@ -41,6 +42,7 @@ from typing_extensions import NotRequired, TypedDict
from langgraph._internal._constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
@@ -49,7 +51,7 @@ from langgraph.config import get_stream_writer
from langgraph.errors import GraphRecursionError, InvalidUpdateError, ParentCommand
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState, add_messages
from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages
from langgraph.pregel import (
NodeBuilder,
Pregel,
@@ -615,8 +617,11 @@ def test_run_from_checkpoint_id_retains_previous_writes(
)
]
assert len(new_history) == len(history) + 1
for original, new in zip(history, new_history[1:]):
# +2: one fork checkpoint from time travel, one from the new execution
assert len(new_history) == len(history) + 2
# new_history[0] is the new execution result, new_history[1] is the fork
assert new_history[1].metadata["source"] == "fork"
for original, new in zip(history, new_history[2:]):
assert original.values == new.values
assert original.next == new.next
assert original.metadata["step"] == new.metadata["step"]
@@ -624,7 +629,7 @@ def test_run_from_checkpoint_id_retains_previous_writes(
def _get_tasks(hist: list, start: int):
return [h.tasks for h in hist[start:]]
assert _get_tasks(new_history, 1) == _get_tasks(history, 0)
assert _get_tasks(new_history, 2) == _get_tasks(history, 0)
def test_batch_two_processes_in_out() -> None:
@@ -1329,20 +1334,22 @@ def test_imp_nested(
}
thread1 = {"configurable": {"thread_id": "1"}}
assert [*graph.stream([0, 1], thread1, durability=durability)] == [
{"submapper": "0"},
result = [*graph.stream([0, 1], thread1, durability=durability)]
# nested tasks run concurrently so output order is non-deterministic
assert sorted(result[:-1], key=lambda d: str(d)) == [
{"mapper": "00"},
{"submapper": "1"},
{"mapper": "11"},
{
"__interrupt__": (
Interrupt(
value="question",
id=AnyStr(),
),
)
},
{"submapper": "0"},
{"submapper": "1"},
]
assert result[-1] == {
"__interrupt__": (
Interrupt(
value="question",
id=AnyStr(),
),
)
}
assert graph.invoke(Command(resume="answer"), thread1, durability=durability) == [
"00answera",
@@ -6269,7 +6276,7 @@ def test_sync_streaming_with_functional_api() -> None:
@task()
def slow() -> dict:
time.sleep(time_delay) # Simulate a delay of 10 ms
return {"tic": time.time()}
return {"tic": time.monotonic()}
@entrypoint()
def graph(inputs: dict) -> list:
@@ -6282,7 +6289,7 @@ def test_sync_streaming_with_functional_api() -> None:
for chunk in graph.stream({}):
if "slow" not in chunk: # We'll just look at the updates from `slow`
continue
arrival_times.append(time.time())
arrival_times.append(time.monotonic())
assert len(arrival_times) == 2
delta = arrival_times[1] - arrival_times[0]
@@ -6891,7 +6898,6 @@ def test_tags_stream_mode_messages() -> None:
"langgraph_path": ("__pregel_pull", "call_model"),
"langgraph_checkpoint_ns": AnyStr("call_model:"),
"checkpoint_ns": AnyStr("call_model:"),
"_type": "generic-fake-chat-model",
"ls_provider": "genericfakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -6901,6 +6907,60 @@ def test_tags_stream_mode_messages() -> None:
]
def test_configurable_propagates_to_stream_metadata() -> None:
"""Regression: thread_id, run_id, assistant_id, graph_id,
and langgraph_auth_user_id from configurable must appear
in stream_mode='messages' metadata."""
def my_node(state):
return {"messages": HumanMessage(content="hello")}
graph = (
StateGraph(MessagesState)
.add_node("my_node", my_node)
.add_edge(START, "my_node")
.compile()
)
config = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
# these should NOT be propagated into metadata
"some_api_key": "secret",
"custom_setting": {"nested": True},
},
}
results = list(graph.stream({"messages": []}, config, stream_mode="messages"))
assert len(results) == 1
_, metadata = results[0]
# propagated keys
assert metadata["thread_id"] == "th-123"
assert metadata["checkpoint_id"] == "ckpt-1"
assert metadata["checkpoint_ns"] == "ns-1"
assert metadata["task_id"] == "task-1"
assert metadata["run_id"] == "run-456"
assert metadata["assistant_id"] == "asst-789"
assert metadata["graph_id"] == "graph-0"
# These are only present in trace metadata by default as of langgraph 1.2
# assert metadata["model"] == "gpt-4o"
# assert metadata["user_id"] == "uid-1"
# assert metadata["cron_id"] == "cron-1"
# assert metadata["langgraph_auth_user_id"] == "user-1"
# non-allowlisted keys must not appear
assert "some_api_key" not in metadata
assert "custom_setting" not in metadata
def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
@@ -9342,3 +9402,254 @@ def test_fork_does_not_apply_pending_writes(
# Should be: 1 (input) + 20 (forked node_a) + 100 (node_b) = 121
assert result == {"value": 121}
async def test_delta_channel_end_to_end_inmemory() -> None:
"""Full graph run: DeltaChannel accumulates correctly across multiple turns."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
n = len(state["messages"])
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diff-test-1"}}
# Turn 1
graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
# Turn 2
graph.invoke({"messages": [HumanMessage(content="world", id="h2")]}, config)
# Turn 3
graph.invoke({"messages": [HumanMessage(content="bye", id="h3")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
# 3 human + 3 AI = 6 total
assert len(msgs) == 6, f"expected 6 messages, got {len(msgs)}: {msgs}"
assert msgs[0].content == "hello"
assert msgs[2].content == "world"
assert msgs[4].content == "bye"
assert msgs[1].content == "reply-1"
assert msgs[3].content == "reply-3"
assert msgs[5].content == "reply-5"
async def test_delta_channel_time_travel() -> None:
"""Time-travel back to turn-1 checkpoint and resume; continuation must not include turn-2 deltas."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
counter = {"n": 0}
def respond(state: State) -> dict:
counter["n"] += 1
return {
"messages": [
AIMessage(content=f"ai-{counter['n']}", id=f"ai-{counter['n']}")
]
}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "diff-time-travel"}}
# Run 2 turns: h1→ai-1, h2→ai-2
graph.invoke({"messages": [HumanMessage(content="h1", id="h1")]}, config)
graph.invoke({"messages": [HumanMessage(content="h2", id="h2")]}, config)
# Find the checkpoint after turn 1 (2 messages: h1 + ai-1)
history = list(graph.get_state_history(config))
after_turn1 = next(h for h in history if len(h.values.get("messages", [])) == 2)
assert len(after_turn1.values["messages"]) == 2
assert after_turn1.values["messages"][0].content == "h1"
assert after_turn1.values["messages"][1].content == "ai-1"
# Resume from turn-1 checkpoint: inject h3, expect 3 messages total (h1, ai-1, ai-N)
# NOT 5 messages (turn-2 deltas must not bleed into the resumed run)
result = graph.invoke(
{"messages": [HumanMessage(content="h3", id="h3")]},
after_turn1.config,
)
msgs = result["messages"]
# Should be: h1, ai-1, h3, ai-N — 4 messages total
assert len(msgs) == 4, (
f"expected 4 messages after time-travel resume, got {len(msgs)}: {msgs}"
)
assert msgs[0].content == "h1"
assert msgs[1].content == "ai-1"
assert msgs[2].content == "h3"
async def test_delta_channel_remove_message_end_to_end() -> None:
"""RemoveMessage inside a DeltaChannel graph must persist and reload correctly."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
return {"messages": [AIMessage(content="reply", id="ai-1")]}
def delete_first(state: State) -> dict:
# removes the first message
return {"messages": [RemoveMessage(id=state["messages"][0].id)]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_node("delete_first", delete_first)
builder.add_edge(START, "respond")
builder.add_edge("respond", "delete_first")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diff-remove-test"}}
graph.invoke({"messages": [HumanMessage(content="hello", id="h1")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
# h1 was removed, only ai-1 should remain
assert len(msgs) == 1, f"expected 1 message, got {len(msgs)}: {msgs}"
assert msgs[0].id == "ai-1"
# A subsequent turn must reconstruct from the checkpoint correctly
graph.invoke({"messages": [HumanMessage(content="again", id="h2")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
# ai-1 + h2 + ai-1(second reply, same id overwrites) + h2 removed
# more simply: after second run we expect ai-1 updated + h2 remaining minus deleted h2
# just assert h1 is still gone
assert all(m.id != "h1" for m in msgs), (
"h1 should still be absent after second turn"
)
async def test_delta_channel_update_by_id_end_to_end() -> None:
"""Updating a message by ID via DeltaChannel must persist and reload correctly."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def update_msg(state: State) -> dict:
# re-send h1 with updated content
return {"messages": [HumanMessage(content="updated", id="h1")]}
builder = StateGraph(State)
builder.add_node("update_msg", update_msg)
builder.add_edge(START, "update_msg")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diff-update-id-test"}}
graph.invoke({"messages": [HumanMessage(content="original", id="h1")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
assert len(msgs) == 1, f"expected 1 message, got {len(msgs)}: {msgs}"
assert msgs[0].content == "updated"
assert msgs[0].id == "h1"
# Second turn: verify the updated state is the base for further accumulation
graph.invoke({"messages": [HumanMessage(content="new", id="h2")]}, config)
state = graph.get_state(config)
msgs = state.values["messages"]
ids = [m.id for m in msgs]
assert "h1" in ids # h1 persists (updated, not duplicated)
assert "h2" in ids
assert ids.count("h1") == 1, "h1 must not be duplicated"
async def test_delta_channel_durability_exit_stores_snapshot() -> None:
"""DeltaChannel must reload from a durability='exit' checkpoint."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
return {"messages": [AIMessage(content="reply", id="ai1")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "delta-exit-test"}}
result = graph.invoke(
{"messages": [HumanMessage(content="hello", id="h1")]},
config,
durability="exit",
)
assert [m.content for m in result["messages"]] == ["hello", "reply"]
state = graph.get_state(config)
assert [m.content for m in state.values["messages"]] == ["hello", "reply"]
async def test_delta_channel_async_write_ordering() -> None:
"""In async mode, DeltaChannel write futures are awaited before the checkpoint
is committed, so aput_writes always precedes aput for sentinel checkpoints."""
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
def respond(state: State) -> dict:
i = len(state["messages"])
return {"messages": [AIMessage(content=f"r{i}", id=f"ai{i}")]}
order: list[str] = []
original_aput_writes = InMemorySaver.aput_writes
original_aput = InMemorySaver.aput
async def tracked_aput_writes(self, config, writes, task_id, task_path=""):
result = await original_aput_writes(self, config, writes, task_id, task_path)
order.append("aput_writes")
return result
async def tracked_aput(self, config, checkpoint, metadata, new_versions):
has_sentinel = any(
v is DELTA_SENTINEL for v in checkpoint.get("channel_values", {}).values()
)
order.append("aput_sentinel" if has_sentinel else "aput_other")
return await original_aput(self, config, checkpoint, metadata, new_versions)
InMemorySaver.aput_writes = tracked_aput_writes
InMemorySaver.aput = tracked_aput
try:
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "async-ordering-test"}}
for i in range(3):
await graph.ainvoke(
{"messages": [HumanMessage(content=f"h{i}", id=f"h{i}")]}, config
)
# Every aput_sentinel must be preceded by at least one aput_writes
for i, event in enumerate(order):
if event == "aput_sentinel":
preceding = order[:i]
assert "aput_writes" in preceding, (
f"aput_sentinel at {i} had no preceding aput_writes: {order}"
)
last_write_idx = max(
j for j, e in enumerate(order[:i]) if e == "aput_writes"
)
assert last_write_idx < i, (
f"aput_writes at {last_write_idx} should precede aput_sentinel at {i}: {order}"
)
finally:
InMemorySaver.aput_writes = original_aput_writes
InMemorySaver.aput = original_aput
state = await graph.aget_state(config)
assert len(state.values["messages"]) == 6 # 3 human + 3 AI
+98 -4
View File
@@ -20,6 +20,7 @@ from uuid import UUID
import pytest
from langchain_core.language_models import GenericFakeChatModel
from langchain_core.messages import HumanMessage
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough
from langchain_core.utils.aiter import aclosing
from langgraph.cache.base import BaseCache
@@ -2085,8 +2086,11 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
)
]
assert len(new_history) == len(history) + 1
for original, new in zip(history, new_history[1:]):
# +2: one fork checkpoint from time travel, one from the new execution
assert len(new_history) == len(history) + 2
# new_history[0] is the new execution result, new_history[1] is the fork
assert new_history[1].metadata["source"] == "fork"
for original, new in zip(history, new_history[2:]):
assert original.values == new.values
assert original.next == new.next
assert original.metadata["step"] == new.metadata["step"]
@@ -2094,7 +2098,7 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
def _get_tasks(hist: list, start: int):
return [h.tasks for h in hist[start:]]
assert _get_tasks(new_history, 1) == _get_tasks(history, 0)
assert _get_tasks(new_history, 2) == _get_tasks(history, 0)
async def test_cond_edge_after_send() -> None:
@@ -6097,6 +6101,36 @@ async def test_parent_command(
)
async def test_delta_channel_durability_exit_stores_snapshot_async() -> None:
"""DeltaChannel must reload from an async durability='exit' checkpoint."""
from langchain_core.messages import AIMessage
from langgraph.channels.delta import DeltaChannel
from langgraph.graph.message import _messages_delta_reducer
class State(TypedDict):
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
async def respond(state: State) -> dict:
return {"messages": [AIMessage(content="reply", id="ai1")]}
builder = StateGraph(State)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "delta-exit-async-test"}}
result = await graph.ainvoke(
{"messages": [HumanMessage(content="hello", id="h1")]},
config,
durability="exit",
)
assert [m.content for m in result["messages"]] == ["hello", "reply"]
state = await graph.aget_state(config)
assert [m.content for m in state.values["messages"]] == ["hello", "reply"]
@NEEDS_CONTEXTVARS
async def test_interrupt_subgraph(async_checkpointer: BaseCheckpointSaver) -> None:
class State(TypedDict):
@@ -7541,7 +7575,6 @@ async def test_tags_stream_mode_messages() -> None:
"langgraph_path": ("__pregel_pull", "call_model"),
"langgraph_checkpoint_ns": AnyStr("call_model:"),
"checkpoint_ns": AnyStr("call_model:"),
"_type": "generic-fake-chat-model",
"ls_provider": "genericfakechatmodel",
"ls_model_type": "chat",
"ls_integration": "langchain_chat_model",
@@ -7551,6 +7584,67 @@ async def test_tags_stream_mode_messages() -> None:
]
async def test_configurable_propagates_to_stream_metadata() -> None:
"""Regression: thread_id, run_id, assistant_id, graph_id,
and langgraph_auth_user_id from configurable must appear
in stream_mode='messages' metadata."""
def my_node(state):
return {"messages": HumanMessage(content="hello")}
graph = (
StateGraph(MessagesState)
.add_node("my_node", my_node)
.add_edge(START, "my_node")
.compile()
)
config = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
# these should NOT be propagated into metadata
"some_api_key": "secret",
"custom_setting": {"nested": True},
},
}
results = [
chunk
async for chunk in graph.astream(
{"messages": []}, config, stream_mode="messages"
)
]
assert len(results) == 1
_, metadata = results[0]
# propagated keys
assert metadata["thread_id"] == "th-123"
assert metadata["checkpoint_id"] == "ckpt-1"
assert metadata["checkpoint_ns"] == "ns-1"
assert metadata["task_id"] == "task-1"
assert metadata["run_id"] == "run-456"
assert metadata["assistant_id"] == "asst-789"
assert metadata["graph_id"] == "graph-0"
# These will only be traced as of langgraph 1.2 and not present by default in
# metadata
# assert metadata["model"] == "gpt-4o"
# assert metadata["user_id"] == "uid-1"
# assert metadata["cron_id"] == "cron-1"
# assert metadata["langgraph_auth_user_id"] == "user-1"
# non-allowlisted keys must not appear
assert "some_api_key" not in metadata
assert "custom_setting" not in metadata
async def test_stream_mode_messages_command() -> None:
from langchain_core.messages import HumanMessage
+10 -7
View File
@@ -501,13 +501,13 @@ async def test_execution_info_populated_in_graph_async() -> None:
assert isinstance(info.node_first_attempt_time, float)
def test_server_info_from_metadata() -> None:
"""server_info is built from assistant_id/graph_id in config metadata."""
def test_server_info_from_configurable() -> None:
"""server_info is built from assistant_id/graph_id in config configurable."""
captured: dict[str, Any] = {}
compiled = _make_capture_graph(captured)
compiled.invoke(
{"message": "hi"},
config={"metadata": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
config={"configurable": {"assistant_id": "asst-abc", "graph_id": "my-graph"}},
)
si = captured["server_info"]
assert si is not None
@@ -516,8 +516,8 @@ def test_server_info_from_metadata() -> None:
assert si.user is None
def test_server_info_none_without_metadata() -> None:
"""server_info is None when no assistant_id/graph_id in metadata."""
def test_server_info_none_without_configurable() -> None:
"""server_info is None when no assistant_id/graph_id in configurable."""
captured: dict[str, Any] = {}
compiled = _make_capture_graph(captured)
compiled.invoke({"message": "hi"})
@@ -579,8 +579,11 @@ def test_server_info_user_from_auth_user() -> None:
compiled.invoke(
{"message": "hi"},
config={
"configurable": {"langgraph_auth_user": proxy},
"metadata": {"assistant_id": "asst-proxy", "graph_id": "graph-proxy"},
"configurable": {
"langgraph_auth_user": proxy,
"assistant_id": "asst-proxy",
"graph_id": "graph-proxy",
},
},
)
si = captured["server_info"]
+791 -7
View File
@@ -37,6 +37,7 @@ def _checkpoint_summary(history: list) -> list[dict]:
Returns a list of dicts (newest-first, matching get_state_history order) with:
- id: short checkpoint id suffix (last 6 chars)
- parent_id: short parent checkpoint id suffix or None
- source: checkpoint metadata source (input, loop, fork, update)
- next: tuple of next node names
- values: channel values snapshot
"""
@@ -52,6 +53,7 @@ def _checkpoint_summary(history: list) -> list[dict]:
{
"id": cid[-6:],
"parent_id": pid[-6:] if pid else None,
"source": s.metadata.get("source"),
"next": s.next,
"values": s.values,
}
@@ -280,6 +282,116 @@ def test_replay_from_before_interrupt_refires(
assert call_count["node_b"] == 1 # NOT re-executed (after interrupt)
def test_replay_from_before_interrupt_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replay from checkpoint before interrupt node, then resume with a new
answer and verify the graph completes with the new value.
Graph: START --> node_a --> ask_human (interrupt) --> node_b --> END
Original run:
source=input next=(__start__,) values=[]
source=loop next=(node_a,) values=[]
source=loop next=(ask_human,) values=[a] <-- replay from here
source=loop next=(node_b,) values=[a, human:old_answer]
source=loop next=() values=[a, human:old_answer, b]
After replay (fork created) + resume with "new_answer":
source=input next=(__start__,) values=[]
source=loop next=(node_a,) values=[]
source=loop next=(ask_human,) values=[a] <-- branch point
source=loop next=(node_b,) values=[a, human:old_answer]
source=loop next=() values=[a, human:old_answer, b] (old branch)
source=fork next=(ask_human,) values=[a] <-- fork from branch point
source=loop next=(node_b,) values=[a, human:new_answer]
source=loop next=() values=[a, human:new_answer, b] (new branch)
"""
called: list[str] = []
def node_a(state: State) -> State:
called.append("node_a")
return {"value": ["a"]}
def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("What is your input?")
return {"value": [f"human:{answer}"]}
def node_b(state: State) -> State:
called.append("node_b")
return {"value": ["b"]}
graph = (
StateGraph(State)
.add_node("node_a", node_a)
.add_node("ask_human", ask_human)
.add_node("node_b", node_b)
.add_edge(START, "node_a")
.add_edge("node_a", "ask_human")
.add_edge("ask_human", "node_b")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: invoke until interrupt, then resume to complete ---
graph.invoke({"value": []}, config)
graph.invoke(Command(resume="old_answer"), config)
original_history = list(graph.get_state_history(config))
original = _checkpoint_summary(original_history)
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Replay from checkpoint before ask_human ---
before_ask = next(s for s in original_history if s.next == ("ask_human",))
called.clear()
replay_result = graph.invoke(None, before_ask.config)
assert replay_result["__interrupt__"][0].value == "What is your input?"
assert "ask_human" in called
assert "node_a" not in called # before the replay point, not re-executed
# A fork checkpoint is now the latest — it branches from the replay point
post_replay = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"]) for s in post_replay] == [
("fork", ("ask_human",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("node_b",)),
("loop", ("ask_human",)), # branch point
("loop", ("node_a",)),
("input", ("__start__",)),
]
# --- Resume with a new answer ---
called.clear()
final_result = graph.invoke(Command(resume="new_answer"), config)
assert final_result["value"] == ["a", "human:new_answer", "b"]
assert "ask_human" in called
assert "node_b" in called
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from fork)
("loop", (), {"value": ["a", "human:new_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:new_answer"]}),
("fork", ("ask_human",), {"value": ["a"]}),
# Original branch (preserved)
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_replay_interrupt_stable_across_replays(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@@ -320,8 +432,14 @@ def test_replay_interrupt_stable_across_replays(
r = graph.invoke(None, before_ask.config)
results.append(r)
assert all(r == results[0] for r in results)
assert "__interrupt__" in results[0]
# Each replay creates a fork with a unique interrupt ID, so we compare
# interrupt values and state values rather than full equality.
assert all("__interrupt__" in r for r in results)
assert all(
r["__interrupt__"][0].value == results[0]["__interrupt__"][0].value
for r in results
)
assert all(r["value"] == results[0]["value"] for r in results)
def test_fork_from_before_interrupt_refires(
@@ -854,6 +972,354 @@ def test_subgraph_interrupt_replay_from_interrupt_checkpoint(
assert "step_b" not in called
def test_subgraph_interrupt_replay_from_parent_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replay from the parent checkpoint where a subgraph interrupt fired,
then resume with a new answer. Verifies that a fork is created and the
full graph completes. Checks full checkpoint history at each stage."""
called: list[str] = []
def router(state: State) -> State:
called.append("router")
return {"value": ["routed"]}
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["sub_a"]}
def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("Provide input:")
return {"value": [f"human:{answer}"]}
def step_b(state: State) -> State:
called.append("step_b")
return {"value": ["sub_b"]}
subgraph = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_human", ask_human)
.add_node("step_b", step_b)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_human")
.add_edge("ask_human", "step_b")
.compile(checkpointer=True)
)
def post_process(state: State) -> State:
called.append("post_process")
return {"value": ["post"]}
graph = (
StateGraph(State)
.add_node("router", router)
.add_node("subgraph_node", subgraph)
.add_node("post_process", post_process)
.add_edge(START, "router")
.add_edge("router", "subgraph_node")
.add_edge("subgraph_node", "post_process")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt, then resume to complete
graph.invoke({"value": []}, config)
graph.invoke(Command(resume="old_answer"), config)
# Original parent history (newest first)
original_history = list(graph.get_state_history(config))
assert [s.next for s in original_history] == [
(), # done
("post_process",),
("subgraph_node",), # subgraph ran, interrupt fired here
("router",),
("__start__",),
]
# Find the parent checkpoint where the interrupt fired
interrupt_checkpoint = next(
s for s in original_history if s.next == ("subgraph_node",)
)
# Replay from parent checkpoint — subgraph re-executes, interrupt re-fires
called.clear()
replay_result = graph.invoke(None, interrupt_checkpoint.config)
assert "__interrupt__" in replay_result
assert replay_result["__interrupt__"][0].value == "Provide input:"
assert "step_a" in called
assert "ask_human" in called
assert "step_b" not in called
# Verify fork checkpoint was created
post_replay_history = list(graph.get_state_history(config))
assert [s.next for s in post_replay_history] == [
("subgraph_node",), # fork (interrupt pending)
(), # original done
("post_process",),
("subgraph_node",),
("router",),
("__start__",),
]
assert [s.metadata["source"] for s in post_replay_history] == [
"fork",
"loop",
"loop",
"loop",
"loop",
"input",
]
fork = post_replay_history[0]
assert (
fork.parent_config["configurable"]["checkpoint_id"]
== interrupt_checkpoint.config["configurable"]["checkpoint_id"]
)
# Resume with a new answer — full graph should complete
called.clear()
final_result = graph.invoke(Command(resume="new_answer"), config)
assert "__interrupt__" not in final_result
assert "human:new_answer" in final_result["value"]
assert "sub_b" in final_result["value"]
assert "post" in final_result["value"]
assert "ask_human" in called
assert "step_b" in called
assert "post_process" in called
# Final checkpoint history
final_history = list(graph.get_state_history(config))
assert [s.next for s in final_history] == [
(), # new branch done
("post_process",), # new branch post_process
("subgraph_node",), # fork
(), # original done
("post_process",),
("subgraph_node",),
("router",),
("__start__",),
]
assert [s.metadata["source"] for s in final_history] == [
"loop",
"loop",
"fork",
"loop",
"loop",
"loop",
"loop",
"input",
]
def test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Resume with Command(resume=...) plus the current head checkpoint_id
in config. The subgraph must continue from the interrupted node, not
restart from scratch. Explicit checkpoint_id triggers is_replaying but
this is a resume, not a time-travel, so ReplayState should not apply."""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["sub_a"]}
def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("Provide input:")
return {"value": [f"human:{answer}"]}
def step_b(state: State) -> State:
called.append("step_b")
return {"value": ["sub_b"]}
subgraph = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_human", ask_human)
.add_node("step_b", step_b)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_human")
.add_edge("ask_human", "step_b")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("subgraph_node", subgraph)
.add_edge(START, "subgraph_node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt fires in subgraph
graph.invoke({"value": []}, config)
assert called == ["step_a", "ask_human"]
# Resume with explicit head checkpoint_id in config
head_checkpoint_id = graph.get_state(config).config["configurable"]["checkpoint_id"]
called.clear()
resume_config = {
"configurable": {
"thread_id": "1",
"checkpoint_id": head_checkpoint_id,
"checkpoint_ns": "",
}
}
result = graph.invoke(Command(resume="answer"), resume_config)
assert called == ["ask_human", "step_b"]
assert "__interrupt__" not in result
assert result["value"] == ["sub_a", "human:answer", "sub_b"]
def test_subgraph_replay_loads_accumulated_state_then_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Two parent invocations, then replay from before the subgraph in the
2nd invocation. The subgraph (checkpointer=True) should load its
accumulated state from the 1st invocation via ReplayState, re-fire
the interrupt, and then resume + complete.
This tests the ReplayState path: the parent is replaying and the
subgraph uses list(before=parent_checkpoint_id) to find its
corresponding checkpoint from the original execution.
"""
class SubState(TypedDict):
value: Annotated[list[str], operator.add]
class ParentState(TypedDict):
results: Annotated[list[str], operator.add]
started_state: list[dict] = []
def step_a(state: SubState) -> SubState:
started_state.append(dict(state))
answer = interrupt("question_a")
return {"value": [f"a:{answer}"]}
subgraph = (
StateGraph(SubState)
.add_node("step_a", step_a)
.add_edge(START, "step_a")
.compile(checkpointer=True)
)
def parent_node(state: ParentState) -> ParentState:
return {"results": ["p"]}
graph = (
StateGraph(ParentState)
.add_node("parent_node", parent_node)
.add_node("sub_node", subgraph)
.add_edge(START, "parent_node")
.add_edge("parent_node", "sub_node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# === 1st invocation: complete with answer "a1" ===
graph.invoke({"results": []}, config)
graph.invoke(Command(resume="a1"), config)
# step_a saw empty state (fresh subgraph)
assert started_state[0] == {"value": []}
# === 2nd invocation: complete with answer "a2" ===
started_state.clear()
graph.invoke({"results": []}, config)
graph.invoke(Command(resume="a2"), config)
# Stateful subgraph retained state from 1st invocation
assert started_state[0] == {"value": ["a:a1"]}
# Original history (newest first)
original_history = list(graph.get_state_history(config))
assert [s.next for s in original_history] == [
(), # 2nd done
("sub_node",), # 2nd sub_node
("parent_node",), # 2nd parent_node
("__start__",), # 2nd input
(), # 1st done
("sub_node",), # 1st sub_node
("parent_node",), # 1st parent_node
("__start__",), # 1st input
]
# Replay from before sub_node in 2nd invocation (newest match)
before_sub_2nd = [s for s in original_history if s.next == ("sub_node",)][0]
started_state.clear()
replay = graph.invoke(None, before_sub_2nd.config)
assert "__interrupt__" in replay
# Subgraph should see accumulated state from END of 1st invocation
assert started_state[0] == {"value": ["a:a1"]}
# Verify fork was created
post_replay_history = list(graph.get_state_history(config))
assert [s.next for s in post_replay_history] == [
("sub_node",), # fork (interrupt pending)
(), # 2nd done
("sub_node",), # 2nd sub_node
("parent_node",), # 2nd parent_node
("__start__",), # 2nd input
(), # 1st done
("sub_node",), # 1st sub_node
("parent_node",), # 1st parent_node
("__start__",), # 1st input
]
assert [s.metadata["source"] for s in post_replay_history] == [
"fork",
"loop",
"loop",
"loop",
"input",
"loop",
"loop",
"loop",
"input",
]
# Resume with a new answer
started_state.clear()
final = graph.invoke(Command(resume="a3"), config)
assert "__interrupt__" not in final
assert final["results"] == ["p", "p"]
# Final history
final_history = list(graph.get_state_history(config))
assert [s.next for s in final_history] == [
(), # new branch done
("sub_node",), # fork
(), # 2nd done
("sub_node",), # 2nd sub_node
("parent_node",), # 2nd parent_node
("__start__",), # 2nd input
(), # 1st done
("sub_node",), # 1st sub_node
("parent_node",), # 1st parent_node
("__start__",), # 1st input
]
assert [s.metadata["source"] for s in final_history] == [
"loop",
"fork",
"loop",
"loop",
"loop",
"input",
"loop",
"loop",
"loop",
"input",
]
def test_subgraph_interrupt_full_flow(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@@ -1290,6 +1756,321 @@ def test_subgraph_time_travel_to_second_interrupt(
assert "ask_1" not in called
def test_subgraph_time_travel_resume_from_first_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the first interrupt, then
resume through both interrupts with new answers.
This verifies the key bug fix: after time-traveling to a subgraph
checkpoint with an interrupt, a fork checkpoint is created so that
subsequent resumes find the correct state (not the old branch tip).
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
Parent history after original run completes:
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[]
source=loop next=() values=[step_a_done, ask_1:answer_1, ask_2:answer_2]
After time-traveling to 1st interrupt + resuming with new answers:
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[] <-- branch point
source=loop next=() values=[..., ask_2:answer_2] (old branch)
source=fork next=(executor,) values=[] <-- fork from time travel
source=loop next=() values=[..., ask_2:new_answer_2] (new branch)
"""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
graph.invoke({"value": []}, config)
sub_config_at_first = graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke(Command(resume="answer_1"), config)
graph.invoke(Command(resume="answer_2"), config)
original = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to first interrupt's subgraph checkpoint ---
called.clear()
replay_result = graph.invoke(None, sub_config_at_first)
assert replay_result["__interrupt__"][0].value == "Question 1?"
assert "step_a" not in called # before interrupt, not re-executed
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume both interrupts with new answers ---
called.clear()
resume_1 = graph.invoke(Command(resume="new_answer_1"), config)
assert resume_1["__interrupt__"][0].value == "Question 2?"
assert "ask_1" in called
called.clear()
resume_2 = graph.invoke(Command(resume="new_answer_2"), config)
assert resume_2["value"] == [
"step_a_done",
"ask_1:new_answer_1",
"ask_2:new_answer_2",
]
# Verify final history: original branch preserved, new branch appended
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:new_answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_subgraph_time_travel_resume_from_second_interrupt(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the second interrupt, then
resume with a new answer. The first interrupt's answer should be preserved.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
Key assertion: after resuming from a time-travel to the 2nd interrupt,
the final state keeps ask_1's original answer but uses the new ask_2 answer.
"""
called: list[str] = []
def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
graph.invoke({"value": []}, config)
graph.invoke(Command(resume="answer_1"), config)
sub_config_at_second = graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke(Command(resume="answer_2"), config)
original = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to second interrupt ---
called.clear()
replay_result = graph.invoke(None, sub_config_at_second)
assert replay_result["__interrupt__"][0].value == "Question 2?"
assert "step_a" not in called
assert "ask_1" not in called # already resolved, not re-executed
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume with a new answer for ask_2 only ---
called.clear()
resume_result = graph.invoke(Command(resume="new_answer_2"), config)
# ask_1's original answer preserved, ask_2 uses the new answer
assert resume_result["value"] == [
"step_a_done",
"ask_1:answer_1",
"ask_2:new_answer_2",
]
# Verify final history: original branch preserved, new branch appended
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_subgraph_time_travel_checkpoint_pattern(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Verify the checkpoint pattern created by time travel to a subgraph
interrupt. A fork checkpoint should branch from the replay point and
become the latest parent checkpoint.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> ask (interrupt) --> END
Original run (after completing):
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[] <-- replay point
source=loop next=() values=[a:first]
After time travel to interrupt + resume with "second":
source=input next=(__start__,) values=[]
source=loop next=(executor,) values=[] <-- branch point
source=loop next=() values=[a:first] (old branch)
source=fork next=(executor,) values=[] <-- fork
source=loop next=() values=[a:second] (new branch)
"""
def ask(state: State) -> State:
answer = interrupt("Q?")
return {"value": [f"a:{answer}"]}
executor = (
StateGraph(State)
.add_node("ask", ask)
.add_edge(START, "ask")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt, then complete
graph.invoke({"value": []}, config)
sub_config = graph.get_state(config, subgraphs=True).tasks[0].state.config
graph.invoke(Command(resume="first"), config)
original = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# Time travel to the interrupt
graph.invoke(None, sub_config)
# Fork is now the latest, branching from the original replay point
post_tt = list(graph.get_state_history(config))
post_tt_summary = _checkpoint_summary(post_tt)
assert [(s["source"], s["next"]) for s in post_tt_summary] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()),
("loop", ("executor",)), # <-- replay point / fork parent
("input", ("__start__",)),
]
# Verify the fork's parent is the original replay point
replay_point_id = sub_config["configurable"]["checkpoint_map"][""]
assert post_tt[0].parent_config["configurable"]["checkpoint_id"] == replay_point_id
# Resume from the fork — graph completes with new answer
result = graph.invoke(Command(resume="second"), config)
assert result["value"] == ["a:second"]
final = _checkpoint_summary(list(graph.get_state_history(config)))
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch
("loop", (), {"value": ["a:second"]}),
("fork", ("executor",), {"value": []}),
# Original branch
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
def test_subgraph_time_travel_after_completion(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@@ -2283,14 +3064,16 @@ def test_replay_creates_branch_preserving_old_checkpoints(
# -- Post-replay checkpoint history (newest first) --
post_replay_history = list(graph.get_state_history(config))
post_summary = _checkpoint_summary(post_replay_history)
assert len(post_summary) == 7 # 5 original + 2 new branch checkpoints
# 5 original + 1 fork + 2 new branch checkpoints = 8
assert len(post_summary) == 8
# Verify the full shape after replay
assert [s["next"] for s in post_summary] == [
(), # new branch tip (C6)
("node_c",), # new branch (C5)
(), # old branch tip (C4)
("node_c",), # old (C3)
(), # new branch tip
("node_c",), # new branch
("node_b",), # fork from replay point
(), # old branch tip
("node_c",), # old
("node_b",), # branch point (C2)
("node_a",), # old (C1)
("__start__",), # old (C0)
@@ -2298,6 +3081,7 @@ def test_replay_creates_branch_preserving_old_checkpoints(
assert [s["values"] for s in post_summary] == [
{"value": ["a", "b2", "c"]}, # new branch tip
{"value": ["a", "b2"]}, # new: node_b re-ran with call_count=2
{"value": ["a"]}, # fork from replay point
{"value": ["a", "b1", "c"]}, # old branch tip preserved
{"value": ["a", "b1"]}, # old
{"value": ["a"]}, # branch point
+403 -7
View File
@@ -46,6 +46,7 @@ def _checkpoint_summary(history: list) -> list[dict]:
Returns a list of dicts (newest-first, matching get_state_history order) with:
- id: short checkpoint id suffix (last 6 chars)
- parent_id: short parent checkpoint id suffix or None
- source: checkpoint metadata source (input, loop, fork, update)
- next: tuple of next node names
- values: channel values snapshot
"""
@@ -61,6 +62,7 @@ def _checkpoint_summary(history: list) -> list[dict]:
{
"id": cid[-6:],
"parent_id": pid[-6:] if pid else None,
"source": s.metadata.get("source"),
"next": s.next,
"values": s.values,
}
@@ -335,8 +337,14 @@ async def test_replay_interrupt_stable_across_replays(
r = await graph.ainvoke(None, before_ask.config)
results.append(r)
assert all(r == results[0] for r in results)
assert "__interrupt__" in results[0]
# Each replay creates a fork with a unique interrupt ID, so we compare
# interrupt values and state values rather than full equality.
assert all("__interrupt__" in r for r in results)
assert all(
r["__interrupt__"][0].value == results[0]["__interrupt__"][0].value
for r in results
)
assert all(r["value"] == results[0]["value"] for r in results)
@NEEDS_CONTEXTVARS
@@ -1261,6 +1269,391 @@ async def test_subgraph_time_travel_after_completion_async(
assert "ask_2:answer_2" in replay_result["value"]
@NEEDS_CONTEXTVARS
async def test_replay_from_before_interrupt_then_resume_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Replay from checkpoint before interrupt node, then resume with a new
answer and verify the graph completes with the new value.
Graph: START --> node_a --> ask_human (interrupt) --> node_b --> END
"""
called: list[str] = []
async def node_a(state: State) -> State:
called.append("node_a")
return {"value": ["a"]}
async def ask_human(state: State) -> State:
called.append("ask_human")
answer = interrupt("What is your input?")
return {"value": [f"human:{answer}"]}
async def node_b(state: State) -> State:
called.append("node_b")
return {"value": ["b"]}
graph = (
StateGraph(State)
.add_node("node_a", node_a)
.add_node("ask_human", ask_human)
.add_node("node_b", node_b)
.add_edge(START, "node_a")
.add_edge("node_a", "ask_human")
.add_edge("ask_human", "node_b")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: invoke until interrupt, then resume to complete ---
await graph.ainvoke({"value": []}, config)
await graph.ainvoke(Command(resume="old_answer"), config)
original_history = [s async for s in graph.aget_state_history(config)]
original = _checkpoint_summary(original_history)
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Replay from checkpoint before ask_human ---
before_ask = next(s for s in original_history if s.next == ("ask_human",))
called.clear()
replay_result = await graph.ainvoke(None, before_ask.config)
assert replay_result["__interrupt__"][0].value == "What is your input?"
assert "ask_human" in called
assert "node_a" not in called
# A fork checkpoint is now the latest
post_replay = _checkpoint_summary(
[s async for s in graph.aget_state_history(config)]
)
assert [(s["source"], s["next"]) for s in post_replay] == [
("fork", ("ask_human",)),
("loop", ()),
("loop", ("node_b",)),
("loop", ("ask_human",)),
("loop", ("node_a",)),
("input", ("__start__",)),
]
# --- Resume with a new answer ---
called.clear()
final_result = await graph.ainvoke(Command(resume="new_answer"), config)
assert final_result["value"] == ["a", "human:new_answer", "b"]
assert "ask_human" in called
assert "node_b" in called
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from fork)
("loop", (), {"value": ["a", "human:new_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:new_answer"]}),
("fork", ("ask_human",), {"value": ["a"]}),
# Original branch (preserved)
("loop", (), {"value": ["a", "human:old_answer", "b"]}),
("loop", ("node_b",), {"value": ["a", "human:old_answer"]}),
("loop", ("ask_human",), {"value": ["a"]}),
("loop", ("node_a",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_subgraph_time_travel_resume_from_first_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the first interrupt, then
resume through both interrupts with new answers.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
"""
called: list[str] = []
async def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
async def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
async def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
await graph.ainvoke({"value": []}, config)
sub_config_at_first = (
(await graph.aget_state(config, subgraphs=True)).tasks[0].state.config
)
await graph.ainvoke(Command(resume="answer_1"), config)
await graph.ainvoke(Command(resume="answer_2"), config)
original = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to first interrupt's subgraph checkpoint ---
called.clear()
replay_result = await graph.ainvoke(None, sub_config_at_first)
assert replay_result["__interrupt__"][0].value == "Question 1?"
assert "step_a" not in called
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume both interrupts with new answers ---
called.clear()
resume_1 = await graph.ainvoke(Command(resume="new_answer_1"), config)
assert resume_1["__interrupt__"][0].value == "Question 2?"
assert "ask_1" in called
called.clear()
resume_2 = await graph.ainvoke(Command(resume="new_answer_2"), config)
assert resume_2["value"] == [
"step_a_done",
"ask_1:new_answer_1",
"ask_2:new_answer_2",
]
# Verify final history: original branch preserved, new branch appended
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:new_answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_subgraph_time_travel_resume_from_second_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Time travel to a subgraph checkpoint at the second interrupt, then
resume with a new answer. The first interrupt's answer should be preserved.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> step_a --> ask_1 (interrupt) --> ask_2 (interrupt) --> END
"""
called: list[str] = []
async def step_a(state: State) -> State:
called.append("step_a")
return {"value": ["step_a_done"]}
async def ask_1(state: State) -> State:
called.append("ask_1")
answer = interrupt("Question 1?")
return {"value": [f"ask_1:{answer}"]}
async def ask_2(state: State) -> State:
called.append("ask_2")
answer = interrupt("Question 2?")
return {"value": [f"ask_2:{answer}"]}
executor = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("ask_1", ask_1)
.add_node("ask_2", ask_2)
.add_edge(START, "step_a")
.add_edge("step_a", "ask_1")
.add_edge("ask_1", "ask_2")
.add_edge("ask_2", "__end__")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# --- Original run: hit both interrupts and resume ---
await graph.ainvoke({"value": []}, config)
await graph.ainvoke(Command(resume="answer_1"), config)
sub_config_at_second = (
(await graph.aget_state(config, subgraphs=True)).tasks[0].state.config
)
await graph.ainvoke(Command(resume="answer_2"), config)
original = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# --- Time travel to second interrupt ---
called.clear()
replay_result = await graph.ainvoke(None, sub_config_at_second)
assert replay_result["__interrupt__"][0].value == "Question 2?"
assert "step_a" not in called
assert "ask_1" not in called
# Fork is now the latest parent checkpoint
post_tt = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"]) for s in post_tt] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()), # original done
("loop", ("executor",)),
("input", ("__start__",)),
]
# --- Resume with a new answer for ask_2 only ---
called.clear()
resume_result = await graph.ainvoke(Command(resume="new_answer_2"), config)
assert resume_result["value"] == [
"step_a_done",
"ask_1:answer_1",
"ask_2:new_answer_2",
]
# Verify final history
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch (from time travel fork)
(
"loop",
(),
{"value": ["step_a_done", "ask_1:answer_1", "ask_2:new_answer_2"]},
),
("fork", ("executor",), {"value": []}),
# Original branch (preserved)
("loop", (), {"value": ["step_a_done", "ask_1:answer_1", "ask_2:answer_2"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_subgraph_time_travel_checkpoint_pattern_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Verify the checkpoint pattern created by time travel to a subgraph
interrupt. A fork checkpoint should branch from the replay point.
Parent: START --> executor (subgraph, checkpointer=True) --> END
Executor: START --> ask (interrupt) --> END
"""
async def ask(state: State) -> State:
answer = interrupt("Q?")
return {"value": [f"a:{answer}"]}
executor = (
StateGraph(State)
.add_node("ask", ask)
.add_edge(START, "ask")
.compile(checkpointer=True)
)
graph = (
StateGraph(State)
.add_node("executor", executor)
.add_edge(START, "executor")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# Run until interrupt, then complete
await graph.ainvoke({"value": []}, config)
sub_config = (await graph.aget_state(config, subgraphs=True)).tasks[0].state.config
await graph.ainvoke(Command(resume="first"), config)
original = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in original] == [
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
# Time travel to the interrupt
await graph.ainvoke(None, sub_config)
# Fork is now the latest, branching from the original replay point
post_tt = [s async for s in graph.aget_state_history(config)]
post_tt_summary = _checkpoint_summary(post_tt)
assert [(s["source"], s["next"]) for s in post_tt_summary] == [
("fork", ("executor",)), # <-- new fork (latest)
("loop", ()),
("loop", ("executor",)), # <-- replay point / fork parent
("input", ("__start__",)),
]
# Verify the fork's parent is the original replay point
replay_point_id = sub_config["configurable"]["checkpoint_map"][""]
assert post_tt[0].parent_config["configurable"]["checkpoint_id"] == replay_point_id
# Resume from the fork
result = await graph.ainvoke(Command(resume="second"), config)
assert result["value"] == ["a:second"]
final = _checkpoint_summary([s async for s in graph.aget_state_history(config)])
assert [(s["source"], s["next"], s["values"]) for s in final] == [
# New branch
("loop", (), {"value": ["a:second"]}),
("fork", ("executor",), {"value": []}),
# Original branch
("loop", (), {"value": ["a:first"]}),
("loop", ("executor",), {"value": []}),
("input", ("__start__",), {"value": []}),
]
@NEEDS_CONTEXTVARS
async def test_3_levels_deep_time_travel_to_first_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
@@ -2088,13 +2481,15 @@ async def test_replay_creates_branch_preserving_old_checkpoints(
# -- Post-replay checkpoint history (newest first) --
post_replay_history = [s async for s in graph.aget_state_history(config)]
post_summary = _checkpoint_summary(post_replay_history)
assert len(post_summary) == 7 # 5 original + 2 new branch checkpoints
# 5 original + 1 fork + 2 new branch checkpoints = 8
assert len(post_summary) == 8
assert [s["next"] for s in post_summary] == [
(), # new branch tip (C6)
("node_c",), # new branch (C5)
(), # old branch tip (C4)
("node_c",), # old (C3)
(), # new branch tip
("node_c",), # new branch
("node_b",), # fork from replay point
(), # old branch tip
("node_c",), # old
("node_b",), # branch point (C2)
("node_a",), # old (C1)
("__start__",), # old (C0)
@@ -2102,6 +2497,7 @@ async def test_replay_creates_branch_preserving_old_checkpoints(
assert [s["values"] for s in post_summary] == [
{"value": ["a", "b2", "c"]}, # new branch tip
{"value": ["a", "b2"]}, # new: node_b re-ran with call_count=2
{"value": ["a"]}, # fork from replay point
{"value": ["a", "b1", "c"]}, # old branch tip preserved
{"value": ["a", "b1"]}, # old
{"value": ["a"]}, # branch point
+115 -5
View File
@@ -11,13 +11,19 @@ from typing import (
TypeVar,
Union,
)
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import langsmith
import pytest
from langchain_core.runnables import RunnableConfig
from langchain_core.tracers import LangChainTracer
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._config import _is_not_empty, ensure_config
from langgraph._internal._config import (
_is_not_empty,
ensure_config,
get_callback_manager_for_config,
)
from langgraph._internal._fields import (
_is_optional_type,
get_enhanced_type_hints,
@@ -298,7 +304,7 @@ def test_is_not_empty() -> None:
assert not _is_not_empty({})
def test_configurable_metadata():
def test_configurable_metadata() -> None:
config = {
"configurable": {
"a-key": "foo",
@@ -309,11 +315,115 @@ def test_configurable_metadata():
"andme": 42,
"nested": {"foo": "bar"},
"nooverride": -2,
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
},
"metadata": {"nooverride": 18},
}
expected = {"includeme", "andme", "nooverride"}
merged = ensure_config(config)
metadata = merged["metadata"]
assert metadata.keys() == expected
assert set(metadata) == {
"nooverride",
"assistant_id",
"thread_id",
"checkpoint_id",
"run_id",
"graph_id",
"checkpoint_ns",
"task_id",
}
assert metadata["nooverride"] == 18
def test_callback_manager_copies_whitelisted_configurable_ids_to_metadata() -> None:
config = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
},
"metadata": {
"thread_id": "from-metadata",
"nooverride": 18,
},
}
manager = ensure_config(config)
callback_manager = get_callback_manager_for_config(manager)
assert callback_manager.metadata == {
"thread_id": "from-metadata",
"nooverride": 18,
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
}
def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
tracer = LangChainTracer(client=MagicMock())
config: RunnableConfig = {
"configurable": {
"thread_id": "th-123",
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"user_id": "uid-1",
"cron_id": "cron-1",
"langgraph_auth_user_id": "user-1",
"includeme": "hi",
"andme": 42,
"__dontinclude": "bar",
"some_api_key": "secret",
"custom_setting": {"nested": True},
},
"metadata": {
"thread_id": "from-metadata",
"user_id": "from-metadata-user",
"includeme": "from-metadata",
},
"callbacks": [tracer],
}
manager = ensure_config(config)
callback_manager = get_callback_manager_for_config(manager)
handlers = callback_manager.handlers
tracers = [handler for handler in handlers if isinstance(handler, LangChainTracer)]
assert len(tracers) == 1
tracer = tracers[0]
assert tracer.tracing_metadata == {
"checkpoint_id": "ckpt-1",
"checkpoint_ns": "ns-1",
"task_id": "task-1",
"run_id": "run-456",
"assistant_id": "asst-789",
"graph_id": "graph-0",
"model": "gpt-4o",
"cron_id": "cron-1",
"andme": 42,
"includeme": "hi",
"thread_id": "th-123",
"user_id": "uid-1",
}
+81 -64
View File
@@ -7,6 +7,9 @@ resolution-markers = [
"python_full_version < '3.11'",
]
[options]
prerelease-mode = "allow"
[[package]]
name = "aiosqlite"
version = "0.22.1"
@@ -524,61 +527,61 @@ toml = [
[[package]]
name = "cryptography"
version = "46.0.6"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "python_full_version >= '3.11' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" },
{ url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" },
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
]
[[package]]
@@ -1348,10 +1351,11 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.22"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
@@ -1360,14 +1364,26 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
]
[[package]]
name = "langgraph"
version = "1.1.6"
version = "1.2.0a2"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1439,7 +1455,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=0.1" },
{ name = "langchain-core", specifier = ">=1.3.2,<2" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -1548,7 +1564,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.1.0a2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -1706,7 +1722,7 @@ inmem = [
requires-dist = [
{ name = "click", specifier = ">=8.1.7" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.8.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.9.0" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.7" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
{ name = "pathspec", specifier = ">=0.11.0" },
@@ -1742,7 +1758,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.9"
version = "1.0.10"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1852,7 +1868,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.6.4"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -1862,11 +1878,12 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[package.optional-dependencies]
@@ -2905,7 +2922,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2916,9 +2933,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
+23 -7
View File
@@ -614,6 +614,7 @@ class _InjectedArgs:
store: str | None
runtime: str | None
all_injected_keys: set[str]
_optional_state_args: set[str]
class ToolNode(RunnableCallable):
@@ -807,6 +808,7 @@ class ToolNode(RunnableCallable):
context=runtime.context,
store=runtime.store,
stream_writer=runtime.stream_writer,
tools=list(self.tools_by_name.values()),
execution_info=runtime.execution_info,
server_info=runtime.server_info,
)
@@ -841,6 +843,7 @@ class ToolNode(RunnableCallable):
context=runtime.context,
store=runtime.store,
stream_writer=runtime.stream_writer,
tools=list(self.tools_by_name.values()),
execution_info=runtime.execution_info,
server_info=runtime.server_info,
)
@@ -1333,7 +1336,7 @@ class ToolNode(RunnableCallable):
return tool_call
tool_call_copy: ToolCall = copy(tool_call)
injected_args = {}
injected_args: dict[str, Any] = {}
# Inject state
if injected.state:
@@ -1361,14 +1364,20 @@ class ToolNode(RunnableCallable):
# Extract state values
if isinstance(state, dict):
for tool_arg, state_field in injected.state.items():
injected_args[tool_arg] = (
state[state_field] if state_field else state
)
if not state_field:
injected_args[tool_arg] = state
elif state_field in state:
injected_args[tool_arg] = state[state_field]
elif tool_arg not in injected._optional_state_args:
raise KeyError(state_field)
else:
for tool_arg, state_field in injected.state.items():
injected_args[tool_arg] = (
getattr(state, state_field) if state_field else state
)
if not state_field:
injected_args[tool_arg] = state
elif hasattr(state, state_field):
injected_args[tool_arg] = getattr(state, state_field)
elif tool_arg not in injected._optional_state_args:
raise AttributeError(state_field)
# Inject store
if injected.store:
@@ -1569,6 +1578,7 @@ class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
- `context`: Runtime context (shared with `Runtime`)
- `store`: `BaseStore` instance for persistent storage (shared with `Runtime`)
- `stream_writer`: `StreamWriter` for streaming output (shared with `Runtime`)
- `tools`: List of all available `BaseTool` instances
No `Annotated` wrapper is needed - just use `runtime: ToolRuntime`
as a parameter.
@@ -1611,6 +1621,7 @@ class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
context: ContextT
config: RunnableConfig
stream_writer: StreamWriter
tools: list[BaseTool]
tool_call_id: str | None
store: BaseStore | None
execution_info: ExecutionInfo | None = None
@@ -1859,6 +1870,7 @@ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
store_arg: str | None = None
runtime_arg: str | None = None
all_injected_keys: set[str] = set()
_optional_state_args: set[str] = set()
for name, type_ in all_annotations.items():
# Track all InjectedToolArg-annotated params (including custom subclasses)
@@ -1873,6 +1885,9 @@ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
if state_inj := _get_injection_from_type(type_, InjectedState):
if isinstance(state_inj, InjectedState) and state_inj.field:
state_args[name] = state_inj.field
field_info = full_schema.model_fields.get(name)
if field_info and not field_info.is_required():
_optional_state_args.add(name)
else:
state_args[name] = None
@@ -1889,4 +1904,5 @@ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
store=store_arg,
runtime=runtime_arg,
all_injected_keys=all_injected_keys,
_optional_state_args=_optional_state_args,
)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.0.9"
version = "1.0.10"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
@@ -0,0 +1,285 @@
"""Test InjectedState with NotRequired state fields.
This tests the fix for https://github.com/langchain-ai/langchain/issues/35585
When using InjectedState(<field>) on a tool parameter, and the referenced field is
declared as NotRequired in the custom state schema, the ToolNode should gracefully
handle missing fields by injecting None instead of raising KeyError.
"""
import sys
from typing import Annotated
import pytest
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langgraph.graph.message import add_messages
from pydantic import BaseModel, Field
from typing_extensions import NotRequired
from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent
from langgraph.prebuilt.chat_agent_executor import AgentState
from .model import FakeToolCallingModel
class CustomAgentStateWithNotRequired(AgentState):
"""Custom state with a NotRequired field (TypedDict style)."""
city: NotRequired[str]
class CustomAgentStatePydanticWithDefault(BaseModel):
"""Custom state with Optional field and default (Pydantic style)."""
messages: Annotated[list[AnyMessage], add_messages]
remaining_steps: int = Field(default=10)
city: str | None = Field(default=None)
@tool
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
"""Get weather for a given city."""
if city is None:
return "No city provided"
return f"It's always sunny in {city}!"
def _create_mock_runtime(
state: dict | None = None,
store=None,
):
"""Create a mock Runtime for testing ToolNode directly."""
from unittest.mock import Mock
from langgraph.runtime import Runtime
mock_runtime = Mock(spec=Runtime)
mock_runtime.context = {}
return mock_runtime
def _create_config_with_runtime(store=None, state=None):
"""Create a RunnableConfig with mocked runtime for direct ToolNode testing."""
from langgraph.prebuilt.tool_node import ToolRuntime
tool_runtime = ToolRuntime(
state=state or {},
config={},
context={},
store=store,
stream_writer=None,
tools=[],
tool_call_id="test_id",
)
return {
"configurable": {
"__pregel_runtime": _create_mock_runtime(),
"__tool_runtime__": tool_runtime,
}
}
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_injected_state_not_required_field_missing_injects_none():
"""Test that InjectedState with NotRequired field injects None when field is missing.
This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
"""
tool_node = ToolNode([get_weather])
tool_call = {
"name": "get_weather",
"args": {},
"id": "call_1",
"type": "tool_call",
}
ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])
# State WITHOUT the "city" field - should inject None instead of raising KeyError
state_without_city: CustomAgentStateWithNotRequired = {
"messages": [HumanMessage("What's the weather?"), ai_msg],
}
result = tool_node.invoke(
state_without_city,
config=_create_config_with_runtime(state=state_without_city),
)
assert len(result["messages"]) == 1
tool_msg = result["messages"][0]
assert isinstance(tool_msg, ToolMessage)
assert "No city provided" in tool_msg.content
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_injected_state_not_required_field_present_works():
"""Test that InjectedState with NotRequired field works when field IS present."""
tool_node = ToolNode([get_weather])
tool_call = {
"name": "get_weather",
"args": {},
"id": "call_1",
"type": "tool_call",
}
ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])
# State WITH the "city" field - this should work
state_with_city: CustomAgentStateWithNotRequired = {
"messages": [HumanMessage("What's the weather?"), ai_msg],
"city": "San Francisco",
}
result = tool_node.invoke(
state_with_city,
config=_create_config_with_runtime(state=state_with_city),
)
assert len(result["messages"]) == 1
tool_msg = result["messages"][0]
assert isinstance(tool_msg, ToolMessage)
assert "San Francisco" in tool_msg.content
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_create_react_agent_injected_state_not_required_field_missing():
"""Test create_react_agent with InjectedState using NotRequired field that is missing.
This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
"""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather],
state_schema=CustomAgentStateWithNotRequired,
)
# Invoke WITHOUT the city field - should work, injecting None
result = agent.invoke(
{"messages": [HumanMessage("What's the weather?")]},
)
# Check that the tool was called successfully with None injected
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "No city provided" in tool_messages[0].content
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
)
def test_create_react_agent_injected_state_not_required_field_present():
"""Test create_react_agent with InjectedState using NotRequired field that IS present."""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather],
state_schema=CustomAgentStateWithNotRequired,
)
# Invoke WITH the city field
result = agent.invoke(
{
"messages": [HumanMessage("What's the weather?")],
"city": "San Francisco",
},
)
# Check that the tool was called successfully
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "San Francisco" in tool_messages[0].content
@tool
def get_weather_optional(city: Annotated[str | None, InjectedState("city")]) -> str:
"""Get weather for a given city (accepts None)."""
if city is None:
return "Please provide a city!"
return f"It's always sunny in {city}!"
def test_pydantic_state_with_default_field_missing_works():
"""Test that Pydantic state with Optional field and default=None works when field is missing.
This is the workaround suggested in the issue comments - using Pydantic BaseModel
with `city: Optional[str] = Field(default=None)` instead of TypedDict with NotRequired.
"""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather_optional],
state_schema=CustomAgentStatePydanticWithDefault,
)
# Invoke WITHOUT the city field - should work because Pydantic provides default
result = agent.invoke(
{"messages": [HumanMessage("What's the weather?")]},
)
# Check that the tool was called successfully with None
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "Please provide a city!" in tool_messages[0].content
def test_pydantic_state_with_default_field_present_works():
"""Test that Pydantic state with Optional field works when field IS present."""
model = FakeToolCallingModel(
tool_calls=[
[{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
[], # No more tool calls, agent should stop
]
)
agent = create_react_agent(
model,
tools=[get_weather_optional],
state_schema=CustomAgentStatePydanticWithDefault,
)
# Invoke WITH the city field
result = agent.invoke(
{
"messages": [HumanMessage("What's the weather?")],
"city": "San Francisco",
},
)
# Check that the tool was called successfully
messages = result["messages"]
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
assert "San Francisco" in tool_messages[0].content
+29 -8
View File
@@ -2016,8 +2016,8 @@ async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async()
assert tool_message.tool_call_id == "call_dynamic_2"
def test_tool_runtime_forwards_execution_info_and_server_info() -> None:
"""Test that execution_info and server_info are forwarded from Runtime to ToolRuntime."""
def test_tool_runtime_forwards_execution_info_server_info_and_tools() -> None:
"""Test that execution_info, server_info, and tools are forwarded from Runtime to ToolRuntime."""
from langgraph.runtime import ExecutionInfo, ServerInfo
exec_info = ExecutionInfo(
@@ -2043,9 +2043,15 @@ def test_tool_runtime_forwards_execution_info_and_server_info() -> None:
"""Tool that captures runtime info."""
captured["execution_info"] = runtime.execution_info
captured["server_info"] = runtime.server_info
captured["tools"] = runtime.tools
return "ok"
node = ToolNode([info_tool])
@dec_tool
def other_tool(y: int) -> str:
"""Another tool available to the runtime."""
return str(y)
node = ToolNode([info_tool, other_tool])
tool_call = {
"name": "info_tool",
"args": {"x": 1},
@@ -2054,17 +2060,21 @@ def test_tool_runtime_forwards_execution_info_and_server_info() -> None:
}
msg = AIMessage("", tool_calls=[tool_call])
config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}}
node.invoke({"messages": [msg]}, config=config)
result = node.invoke({"messages": [msg]}, config=config)
assert result["messages"][-1].content == "ok"
assert captured["execution_info"] is exec_info
assert captured["execution_info"].thread_id == "t-1"
assert captured["execution_info"].task_id == "tk-1"
assert captured["server_info"] is server_info
assert captured["server_info"].assistant_id == "asst-1"
assert [tool.name for tool in captured["tools"]] == ["info_tool", "other_tool"]
async def test_tool_runtime_forwards_execution_info_and_server_info_async() -> None:
"""Test that execution_info and server_info are forwarded in async path."""
async def test_tool_runtime_forwards_execution_info_server_info_and_tools_async() -> (
None
):
"""Test that execution_info, server_info, and tools are forwarded in async path."""
from langgraph.runtime import ExecutionInfo, ServerInfo
exec_info = ExecutionInfo(
@@ -2090,9 +2100,15 @@ async def test_tool_runtime_forwards_execution_info_and_server_info_async() -> N
"""Async tool that captures runtime info."""
captured["execution_info"] = runtime.execution_info
captured["server_info"] = runtime.server_info
captured["tools"] = runtime.tools
return "ok"
node = ToolNode([info_tool_async])
@dec_tool
async def other_tool_async(y: int) -> str:
"""Another async tool available to the runtime."""
return str(y)
node = ToolNode([info_tool_async, other_tool_async])
tool_call = {
"name": "info_tool_async",
"args": {"x": 1},
@@ -2101,12 +2117,17 @@ async def test_tool_runtime_forwards_execution_info_and_server_info_async() -> N
}
msg = AIMessage("", tool_calls=[tool_call])
config: RunnableConfig = {"configurable": {"__pregel_runtime": mock_runtime}}
await node.ainvoke({"messages": [msg]}, config=config)
result = await node.ainvoke({"messages": [msg]}, config=config)
assert result["messages"][-1].content == "ok"
assert captured["execution_info"] is exec_info
assert captured["execution_info"].thread_id == "t-2"
assert captured["server_info"] is server_info
assert captured["server_info"].graph_id == "graph-2"
assert [tool.name for tool in captured["tools"]] == [
"info_tool_async",
"other_tool_async",
]
# --- InjectedToolArg security tests ---
+14 -13
View File
@@ -249,7 +249,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.25"
version = "1.3.0"
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/86/2a/d65de24fc9b7989137253da8973f850f3e39b4ce3e0377bc8200d6b3c189/langchain_core-1.2.25.tar.gz", hash = "sha256:77e032b96509d0eb1f6875042fdf97b7e2334a815314700c6894d9d078909b9c", size = 842347, upload-time = "2026-04-02T22:39:11.528Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/0e/7b31b0249f9b9b0fc7829d5b0ee484b8f8d43c78e376e9951e2ef3eac70c/langchain_core-1.2.25-py3-none-any.whl", hash = "sha256:0c05bf395aec6d2dfa14488fd006f7bcd0540e7e89287e04f92203532a82c828", size = 506866, upload-time = "2026-04-02T22:39:10.137Z" },
{ 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" },
]
[[package]]
name = "langgraph"
version = "1.1.6"
version = "1.1.9"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -281,7 +281,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=0.1" },
{ name = "langchain-core", specifier = ">=1.3.0,<2" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "." },
{ name = "langgraph-sdk", editable = "../sdk-py" },
@@ -352,7 +352,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.0.2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -490,7 +490,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.9"
version = "1.0.10"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -619,7 +619,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.6.4"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -629,11 +629,12 @@ dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "uuid-utils" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1182,7 +1183,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1193,9 +1194,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
+13 -13
View File
@@ -262,7 +262,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.22"
version = "1.3.0"
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/b1/a3/c4cd6827a1df46c821e7214b7f7b7a28b189e6c9b84ef15c6d629c5e3179/langchain_core-1.2.22.tar.gz", hash = "sha256:8d8f726d03d3652d403da915126626bb6250747e8ba406537d849e68b9f5d058", size = 842487, upload-time = "2026-03-24T18:48:44.9Z" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/a6/2ffacf0f1a3788f250e75d0b52a24896c413be11be3a6d42bcdf46fbea48/langchain_core-1.2.22-py3-none-any.whl", hash = "sha256:7e30d586b75918e828833b9ec1efc25465723566845dd652c277baf751e9c04b", size = 506829, upload-time = "2026-03-24T18:48:43.286Z" },
{ 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" },
]
[[package]]
name = "langgraph"
version = "1.1.6"
version = "1.1.9"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -294,7 +294,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", specifier = ">=0.1" },
{ name = "langchain-core", specifier = ">=1.3.0,<2" },
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "." },
@@ -365,7 +365,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.0.1"
version = "4.0.2"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
@@ -413,7 +413,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.9"
version = "1.0.10"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -534,7 +534,7 @@ test = [
[[package]]
name = "langsmith"
version = "0.7.20"
version = "0.7.31"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -547,9 +547,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/c6/cbdc6638207f68a3c61ec0b64fa593f6b11de3170d03c852238c31b54960/langsmith-0.7.20.tar.gz", hash = "sha256:fa983a74f75648ee0e80d3f9751162b6f9a438896d5f9bdb6cba9abda451e234", size = 1134732, upload-time = "2026-03-18T00:03:39.129Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/46/9294d4f49de6a8f08e8b83907713ca545459d87d474c6add15d31a36f5dc/langsmith-0.7.20-py3-none-any.whl", hash = "sha256:0162faf791ea48d69009a12a3da917468556b99cf5d5fcacbb8cda064262e118", size = 359314, upload-time = "2026-03-18T00:03:37.59Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
]
[[package]]
@@ -1000,7 +1000,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1011,9 +1011,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]