Compare commits

..
Author SHA1 Message Date
Sydney Runkle bbc35a30f8 POC streaming hints 2026-02-23 09:09:13 -05:00
William FHandGitHub b0f14649e0 chore: Update CLI schema (#6858) 2026-02-20 01:11:21 +00:00
ea20432b9b fix: bump js-yaml to 3.14.2 to resolve CVE-2025-64718 (#6879)
## Security Alert Patch

Resolves 1 Dependabot security alert (medium severity).

### Package Updated

| Package | Old Version | New Version | Strategy | CVE Resolved |
|---------|-------------|-------------|----------|--------------|
| `js-yaml` | 3.14.1 | 3.14.2 | Lockfile patch (within-range bump) |
CVE-2025-64718 |

### CVE Details

**CVE-2025-64718** /
[GHSA-mh29-5h37-fv8m](https://github.com/advisories/GHSA-mh29-5h37-fv8m)
— `js-yaml` prototype pollution via YAML merge keys (`<<`). Affects
versions < 3.14.2.

The vulnerable package is a transitive dev dependency pulled in by
`@istanbuljs/load-nyc-config@1.1.0` (a Jest internal). No runtime
impact.

### Fix Strategy

Lockfile-only patch in `libs/cli/js-examples/yarn.lock`. The `^3.13.1`
version range already allows 3.14.2, so no manifest changes were needed.
The `js-yaml@^4.1.1` entry (used by `@eslint/eslintrc`) is untouched.

### Verification

- [x] Lockfile updated — `js-yaml@^3.13.1` now resolves to `3.14.2`
- [x] `js-yaml@^4.1.1` entry unchanged (`4.1.1`)
- [x] `yarn install --frozen-lockfile` passes

🤖 Submitted by langster-patch

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 19:41:51 +00:00
William FHandGitHub e2efab8061 release(sdk-py): 0.3.8 (#6873)
Includes some updated docstrings.
2026-02-19 11:10:19 -08:00
9babffa054 feat(sdk-py): add stream_mode, stream_subgraphs, stream_resumable, durability to crons (#6876)
## Summary
- Adds `stream_mode`, `stream_subgraphs`, `stream_resumable`, and
`durability` parameters to cron `create`, `create_for_thread`, and
`update` methods in both async and sync clients
- Adds corresponding fields to the `CronUpdate` TypedDict in `schema.py`
- Adds `checkpoint_during` deprecation warnings to cron create methods
(consistent with the runs client pattern)

These fields were added to the OpenAPI spec in langgraph-api but were
not yet reflected in the Python SDK.

## Test plan
- [x] `make format` passes
- [x] `make lint` passes
- [x] `make test` passes (69/69, including sync/async API parity test)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:10:07 -08:00
Sydney RunkleandGitHub 73cebea3c2 release: langgraph + prebuilt (#6875) 2026-02-19 13:12:01 -05:00
b73b2d19eb fix: inject ToolRuntime for dynamically registered tools (#6874)
Fixes https://github.com/langchain-ai/langchain/issues/35305

Co-authored-by: Shivangi Sharma <shivangi.sharma7004@gmail.com>
2026-02-19 17:39:57 +00:00
ca26805b5f fix: sequential interrupt handling w/ functional API (#6863)
In the original proposed fix
https://github.com/langchain-ai/langgraph/pull/6863, we were guarding
this specific case by only checking for null resume in the scratchpad.

But I noticed the tests passed on main for the sync case, indicating an
inconsistency between the two paths.

Seems like we were making a redundant put_writes call when replaying the
async tasks

---------

Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
2026-02-19 07:24:18 -08:00
5ac837d7cd feat(sdk-py): improve store auth type safety and docstrings (#6867)
## Summary
- Add `_StoreActionOn` class with action-specific decorator properties
(`.put`, `.get`, `.search`, `.delete`, `.list_namespaces`) to
`_StoreOn`, enabling `@auth.on.store.put` etc. which was documented but
not implemented
- Update docstring examples to show namespace-rewriting pattern as the
canonical store auth approach
- Fix `AuthContext.action` docstring: add missing `search` and `delete`
store actions
- Fix typo in `StoreSearch.query` docstring
- Add auth-handler-aware docstrings to all store TypedDicts

## Test plan
- [x] `make format && make lint` passes in `libs/sdk-py`
- [] Verify `@auth.on.store.put` decorator works at runtime

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:16:26 -08:00
Lauren Hirata SinghandGitHub c4f5861166 chore(docs): Add new redirects file(s) so we can update/add (#6778)
Adds redirects for all old LangGraph docs URLs to docs.langchain.com
using meta refresh and GitHub Pages
2026-02-18 14:35:38 -05:00
Parker J. RuleandGitHub 172238b2d5 fix(sdk-py): allow reset of config/context in assistants update (#6862)
This distinguishes an empty `config`/`context` (`{}`) from a null
`config`/`context`, and therefore allows resetting these fields.

(Easiest to test this E2E on the server side; will create a separate PR
for that.)
2026-02-18 18:48:07 +00:00
William FHandGitHub 095da17833 chore: state_updated_at sort by (#6857) 2026-02-18 10:41:23 -08:00
John KennedyandGitHub e931c68669 fix: actual fix for workflow (#6854)
Another attempt at fixing `_integration_test.yml`
2026-02-17 11:31:08 -08:00
John KennedyandGitHub 666c224c2d chore: bump orjson (#6852)
The orjson.dumps function in orjson thru 3.11.4 does not limit recursion
for deeply nested JSON documents.
2026-02-17 10:30:58 -08:00
William FHandGitHub 21a6f41e0a chore: add testpypi index (#6853) 2026-02-17 10:24:39 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
acdf85aba6 chore(deps): bump langgraph-sdk from 0.3.5 to 0.3.6 in /libs/cli in the all-dependencies group (#6851)
Bumps the all-dependencies group in /libs/cli with 1 update:
[langgraph-sdk](https://github.com/langchain-ai/langgraph).

Updates `langgraph-sdk` from 0.3.5 to 0.3.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/langchain-ai/langgraph/releases">langgraph-sdk's
releases</a>.</em></p>
<blockquote>
<h2>cli==0.3.6</h2>
<p>Changes since cli==0.3.5</p>
<ul>
<li>add api-version option</li>
<li>chore(langgraph): bump api version and prep for v0.6 alpha (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/5559">#5559</a>)</li>
<li>feat(langgraph): new context api (replacing
<code>config['configurable']</code> and <code>config_schema</code>) (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/5243">#5243</a>)</li>
<li>langgraph[change]: solidify public/private differentiations (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/5252">#5252</a>)</li>
</ul>
<h2>langgraph-sdk==0.3.6</h2>
<p>Changes since sdk==0.3.5</p>
<ul>
<li>release(sdk-py): 0.3.6 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/6805">#6805</a>)</li>
<li>chore: update to add prune method (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/6804">#6804</a>)</li>
<li>chore: Re-organize client files. (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/6787">#6787</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/ca8d92421a9832620c071435807ca6dd14449fad"><code>ca8d924</code></a>
langgraph: release 0.3.6 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/3775">#3775</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/7aa9d3fd003545c1df9b9d2690cf4863ec6adefb"><code>7aa9d3f</code></a>
langgraph: use input schema from conditional edge (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/2516">#2516</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/3a4af1e5737eeac1d292cefb5325d1114c9e0787"><code>3a4af1e</code></a>
chore(deps): bump axios from 1.7.7 to 1.8.2 in /libs/sdk-js (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/3740">#3740</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/37344124e17453c712207624c045c646f8827eb3"><code>3734412</code></a>
Fix updated_at timestamp loading (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/3767">#3767</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/d0f4db6ddd624c4a8be0164fc2af319c56836820"><code>d0f4db6</code></a>
feat(sdk-js): use fetchClient from <code>client</code> in gen ui (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/3761">#3761</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/a9800aab876f78017fd633ffcbb50236d04d54bd"><code>a9800aa</code></a>
checkpoint-sqlite: release 2.0.6 (<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/3763">#3763</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/9bf3fc2d0fa9c67e1a2725fa9f8012cd31146c11"><code>9bf3fc2</code></a>
checkpoint-sqlite: commit transactions in AsyncSqliteSaver.aput_writes
(<a
href="https://redirect.github.com/langchain-ai/langgraph/issues/3762">#3762</a>)</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/a6e4bd93ff930ce60e5103c121ec9440db7954a3"><code>a6e4bd9</code></a>
Bump to 0.0.52</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/0e9c41f48059aa37e98f342720982ec8bfd5ae41"><code>0e9c41f</code></a>
feat(sdk-js): use fetchClient from <code>client</code> in gen ui</li>
<li><a
href="https://github.com/langchain-ai/langgraph/commit/3808302309f1ccc6951148367f597df5fabf5764"><code>3808302</code></a>
Bump to 0.0.51</li>
<li>Additional commits viewable in <a
href="https://github.com/langchain-ai/langgraph/compare/0.3.5...0.3.6">compare
view</a></li>
</ul>
</details>
<br />


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


</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-02-17 10:14:06 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
9b18243fa6 chore(deps): bump the all-dependencies group in /libs/checkpoint with 2 updates (#6850)
Bumps the all-dependencies group in /libs/checkpoint with 2 updates:
[langchain-core](https://github.com/langchain-ai/langchain) and
[redis](https://github.com/redis/redis-py).

Updates `langchain-core` from 1.2.12 to 1.2.13
<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.13</h2>
<p>Changes since langchain-core==1.2.12</p>
<p>release(core): 1.2.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35230">#35230</a>)
docs(core): expanded <code>get_lc_namespace</code> docstring (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35229">#35229</a>)
feat(openrouter): add <code>langchain-openrouter</code> provider package
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35211">#35211</a>)
style: bump ruff version to 0.15 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35042">#35042</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b026fd605b0cc150f943e6ea8a8aed45e23373e5"><code>b026fd6</code></a>
release(core): 1.2.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35230">#35230</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/1573757b3bf53bab48b7ff3804707adf3a173e96"><code>1573757</code></a>
docs(core): expanded <code>get_lc_namespace</code> docstring (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35229">#35229</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/f9fd7be695c70f33cb6cbeb4cca1547ee225c5b1"><code>f9fd7be</code></a>
feat(openrouter): add <code>langchain-openrouter</code> provider package
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35211">#35211</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b97c629f9ade098236aa56d861a21706a9830f54"><code>b97c629</code></a>
style: bump ruff version to 0.15 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35042">#35042</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/1493b4c5eed3b6c08358a5ce5d890bc8b52b4874"><code>1493b4c</code></a>
fix: Server-Side Request Forgery (SSRF) in
HTMLHeaderTextSplitter.split_text_...</li>
<li>See full diff in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.12...langchain-core==1.2.13">compare
view</a></li>
</ul>
</details>
<br />

Updates `redis` from 7.1.1 to 7.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/redis/redis-py/releases">redis's
releases</a>.</em></p>
<blockquote>
<h2>7.2.0</h2>
<h1>Changes</h1>
<p><strong>Redis 8.6 Support</strong>
Added support for Redis 8.6, including new commands and features for
streams idempotent production and HOTKEYS.</p>
<p><strong>Smart Client Handoff (Maintenance Notifications) for Cluster
note: Pending a Redis Enterprise version release</strong></p>
<p>This release introduces comprehensive support for Redis Enterprise
Cluster maintenance notifications via SMIGRATING/SMIGRATED push
notifications. The client now automatically handles slot migrations
by:</p>
<p>Relaxing timeouts during migration (SMIGRATING) to prevent false
failures
Triggering cluster state reloads upon completion (SMIGRATED)
Enabling seamless operations during Redis Enterprise maintenance
windows</p>
<p><strong>OpenTelemetry Native Metrics Support</strong>
Added comprehensive OpenTelemetry metrics support following the <a
href="https://opentelemetry.io/docs/specs/semconv/database/database-metrics/">OpenTelemetry
Database Client Semantic Conventions</a>.
Metric groups include:</p>
<ul>
<li>Command metrics: Operation duration with retry tracking</li>
<li>Connection basic: Connection count and creation time</li>
<li>Resiliency: Errors, handoffs, timeout relaxation</li>
<li>Connection advanced: Wait time and use time</li>
<li>Pubsub metrics: Published and received messages</li>
<li>Stream metrics: Processing duration and maintenance
notifications</li>
</ul>
<h2>🚀 New Features</h2>
<ul>
<li>Added OTel instrumentation and metrics export for sync client (<a
href="https://redirect.github.com/redis/redis-py/issues/3954">#3954</a>)</li>
<li>Add maintenance notifications support for OSS API cluster clients
(<a
href="https://redirect.github.com/redis/redis-py/issues/3946">#3946</a>)</li>
<li>Adding hotkeys commands support. (<a
href="https://redirect.github.com/redis/redis-py/issues/3924">#3924</a>)</li>
<li>Adds support for the new Idempotent Producers feature in Redis 8.6
(<a
href="https://redirect.github.com/redis/redis-py/issues/3926">#3926</a>)</li>
<li>Adding support for vrange command. (<a
href="https://redirect.github.com/redis/redis-py/issues/3927">#3927</a>)</li>
<li>Added a local digest command to the client to execute the XXH3
locally… (<a
href="https://redirect.github.com/redis/redis-py/issues/3884">#3884</a>)</li>
<li>Add DriverInfo class for upstream driver tracking (<a
href="https://redirect.github.com/redis/redis-py/issues/3880">#3880</a>)</li>
<li>Add ssl_password support to async Redis client (<a
href="https://redirect.github.com/redis/redis-py/issues/3878">#3878</a>)</li>
<li>Add ssl_ca_path support to async Redis client (<a
href="https://redirect.github.com/redis/redis-py/issues/3879">#3879</a>)</li>
</ul>
<h2>⚠️ Deprecations</h2>
<ul>
<li>Adding special handling of client_tracking_on and
client_tracking_off for cluster clients. Marking those two functions as
deprecated for cluster - embedded client-side caching feature should be
used instead. (<a
href="https://redirect.github.com/redis/redis-py/issues/3858">#3858</a>)</li>
</ul>
<h2>🐛 Bug Fixes</h2>
<ul>
<li>Fix handling of circular MOVED redirects in cluster slot mapping (<a
href="https://redirect.github.com/redis/redis-py/issues/3899">#3899</a>)</li>
<li>fix(asyncio): prevent deadlock when Lock.release() is cancelled (<a
href="https://redirect.github.com/redis/redis-py/issues/3900">#3900</a>)</li>
<li>Fix unrecoverable connection state (<a
href="https://redirect.github.com/redis/redis-py/issues/3905">#3905</a>)</li>
<li>Fixed potential race condition between call_later() and
run_forever() (<a
href="https://redirect.github.com/redis/redis-py/issues/3897">#3897</a>)</li>
<li>Fix RuntimeError in ClusterPubSub sharded message generator (<a
href="https://redirect.github.com/redis/redis-py/issues/3889">#3889</a>)</li>
<li>Fix async connection pool lock contention during connection
establishment (<a
href="https://redirect.github.com/redis/redis-py/issues/3885">#3885</a>)</li>
<li>Fix PubSub client health check handling for sync client
implementation (<a
href="https://redirect.github.com/redis/redis-py/issues/3870">#3870</a>)</li>
<li>Adding retries for the overall connect - socket connect + handshake.
Fix for pubsub reconnect issues. (<a
href="https://redirect.github.com/redis/redis-py/issues/3863">#3863</a>)</li>
<li>Adding special handling of client_tracking_on and
client_tracking_off for cluster clients. Marking those two functions as
deprecated for cluster - embedded client-side caching feature should be
used instead. (<a
href="https://redirect.github.com/redis/redis-py/issues/3858">#3858</a>)</li>
<li><a
href="https://redirect.github.com/redis/redis-py/issues/3618">#3618</a>
Fix client-side cache invalidation for mixed str and bytes Redis keys
(<a
href="https://redirect.github.com/redis/redis-py/issues/3766">#3766</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/redis/redis-py/commit/915e8b41d2bd3cb053f82dd4faa72a0ca55f2ce2"><code>915e8b4</code></a>
Updating server version to 7.2.0</li>
<li><a
href="https://github.com/redis/redis-py/commit/b41e47d76911563232e09a95c05ccc09c010def6"><code>b41e47d</code></a>
Added OTel instrumentation and metrics export for sync client (<a
href="https://redirect.github.com/redis/redis-py/issues/3954">#3954</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/9c8ad166a83929656fd00d52f65072890f7b7f1e"><code>9c8ad16</code></a>
Fix type hints for spop and srandmember methods (<a
href="https://redirect.github.com/redis/redis-py/issues/3943">#3943</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/34c73c783ec2e8ade103ad2d324630cff6a26d58"><code>34c73c7</code></a>
Add maintenance notifications support for OSS API cluster clients (<a
href="https://redirect.github.com/redis/redis-py/issues/3946">#3946</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/24974feafdcd1a03bc2254fbdfd028316106ab0e"><code>24974fe</code></a>
Renamed initial health check policies and unhealthy database argument
(<a
href="https://redirect.github.com/redis/redis-py/issues/3949">#3949</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/3d6b571e3df69c1ab0060e7a53fea7ac0c50a725"><code>3d6b571</code></a>
Reorganize pipeline actions (<a
href="https://redirect.github.com/redis/redis-py/issues/3951">#3951</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/877d7a2987ca388489fbe221df806e5cc9cc51a9"><code>877d7a2</code></a>
Disabled SCH in MultiDBClient underlying clients (<a
href="https://redirect.github.com/redis/redis-py/issues/3938">#3938</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/9ac9ee6ce13f4b292b1d2d3db81ef0f30afe29b4"><code>9ac9ee6</code></a>
Adding hotkeys commands support. (<a
href="https://redirect.github.com/redis/redis-py/issues/3924">#3924</a>)</li>
<li><a
href="https://github.com/redis/redis-py/commit/65623b7fad9833b5281d6ce7f44f62f942621234"><code>65623b7</code></a>
Extending the tests to validate more supported scorers for hybrid
search. Add...</li>
<li><a
href="https://github.com/redis/redis-py/commit/2b2f2cf5416862d15399f422c3658c0e1d671a10"><code>2b2f2cf</code></a>
Adds support for the new Idempotent Producers feature in Redis 8.6 (<a
href="https://redirect.github.com/redis/redis-py/issues/3926">#3926</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/redis/redis-py/compare/v7.1.1...v7.2.0">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


</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-02-17 10:13:43 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
0225b998af chore(deps): bump the all-dependencies group in /libs/cli/js-examples with 2 updates (#6848)
Bumps the all-dependencies group in /libs/cli/js-examples with 2
updates:
[@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin)
and
[@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser).

Updates `@typescript-eslint/eslint-plugin` from 8.55.0 to 8.56.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/eslint-plugin</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.56.0</h2>
<h2>8.56.0 (2026-02-16)</h2>
<h3>🚀 Features</h3>
<ul>
<li>support ESLint v10 (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12057">#12057</a>)</li>
</ul>
<h3>🩹 Fixes</h3>
<ul>
<li>use parser options from context.languageOptions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12043">#12043</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Brad Zacher <a
href="https://github.com/bradzacher"><code>@​bradzacher</code></a></li>
<li>fnx <a
href="https://github.com/DMartens"><code>@​DMartens</code></a></li>
<li>Joshua Chen</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.56.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md"><code>@​typescript-eslint/eslint-plugin</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.56.0 (2026-02-16)</h2>
<h3>🚀 Features</h3>
<ul>
<li>support ESLint v10 (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12057">#12057</a>)</li>
</ul>
<h3>🩹 Fixes</h3>
<ul>
<li>use parser options from context.languageOptions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12043">#12043</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Brad Zacher <a
href="https://github.com/bradzacher"><code>@​bradzacher</code></a></li>
<li>fnx <a
href="https://github.com/DMartens"><code>@​DMartens</code></a></li>
<li>Joshua Chen</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.56.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/8b8b68f200a48ee9d6f7be8670e964629375196c"><code>8b8b68f</code></a>
chore(release): publish 8.56.0</li>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/68a074fb61a10c3627f09678859fb4d42a3b88f5"><code>68a074f</code></a>
feat: support ESLint v10 (<a
href="https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin/issues/12057">#12057</a>)</li>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/c0a359df266e2ff4903eff2fdfa6c8ea30b84bca"><code>c0a359d</code></a>
fix: use parser options from context.languageOptions (<a
href="https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin/issues/12043">#12043</a>)</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.56.0/packages/eslint-plugin">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/parser` from 8.55.0 to 8.56.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/parser</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.56.0</h2>
<h2>8.56.0 (2026-02-16)</h2>
<h3>🚀 Features</h3>
<ul>
<li>support ESLint v10 (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12057">#12057</a>)</li>
</ul>
<h3>🩹 Fixes</h3>
<ul>
<li>use parser options from context.languageOptions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12043">#12043</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Brad Zacher <a
href="https://github.com/bradzacher"><code>@​bradzacher</code></a></li>
<li>fnx <a
href="https://github.com/DMartens"><code>@​DMartens</code></a></li>
<li>Joshua Chen</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.56.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md"><code>@​typescript-eslint/parser</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.56.0 (2026-02-16)</h2>
<h3>🚀 Features</h3>
<ul>
<li>support ESLint v10 (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12057">#12057</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Brad Zacher <a
href="https://github.com/bradzacher"><code>@​bradzacher</code></a></li>
<li>Joshua Chen</li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.56.0">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/8b8b68f200a48ee9d6f7be8670e964629375196c"><code>8b8b68f</code></a>
chore(release): publish 8.56.0</li>
<li><a
href="https://github.com/typescript-eslint/typescript-eslint/commit/68a074fb61a10c3627f09678859fb4d42a3b88f5"><code>68a074f</code></a>
feat: support ESLint v10 (<a
href="https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser/issues/12057">#12057</a>)</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.56.0/packages/parser">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


</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-02-17 10:12:44 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John Kennedy
bec122d4a2 chore(deps): bump langchain-core from 1.2.12 to 1.2.13 in /libs/prebuilt in the all-dependencies group (#6849)
Bumps the all-dependencies group in /libs/prebuilt with 1 update:
[langchain-core](https://github.com/langchain-ai/langchain).

Updates `langchain-core` from 1.2.12 to 1.2.13
<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.13</h2>
<p>Changes since langchain-core==1.2.12</p>
<p>release(core): 1.2.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35230">#35230</a>)
docs(core): expanded <code>get_lc_namespace</code> docstring (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35229">#35229</a>)
feat(openrouter): add <code>langchain-openrouter</code> provider package
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35211">#35211</a>)
style: bump ruff version to 0.15 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35042">#35042</a>)</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b026fd605b0cc150f943e6ea8a8aed45e23373e5"><code>b026fd6</code></a>
release(core): 1.2.13 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35230">#35230</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/1573757b3bf53bab48b7ff3804707adf3a173e96"><code>1573757</code></a>
docs(core): expanded <code>get_lc_namespace</code> docstring (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35229">#35229</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/f9fd7be695c70f33cb6cbeb4cca1547ee225c5b1"><code>f9fd7be</code></a>
feat(openrouter): add <code>langchain-openrouter</code> provider package
(<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35211">#35211</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/b97c629f9ade098236aa56d861a21706a9830f54"><code>b97c629</code></a>
style: bump ruff version to 0.15 (<a
href="https://redirect.github.com/langchain-ai/langchain/issues/35042">#35042</a>)</li>
<li><a
href="https://github.com/langchain-ai/langchain/commit/1493b4c5eed3b6c08358a5ce5d890bc8b52b4874"><code>1493b4c</code></a>
fix: Server-Side Request Forgery (SSRF) in
HTMLHeaderTextSplitter.split_text_...</li>
<li>See full diff in <a
href="https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.12...langchain-core==1.2.13">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.12&new-version=1.2.13)](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 <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


</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-02-17 10:11:53 -08:00
79 changed files with 2707 additions and 9866 deletions
+5 -3
View File
@@ -31,6 +31,8 @@ jobs:
workdir: libs/cli/examples/graphs_reqs_b
tag: langgraph-test-d
name: "CLI integration test"
env:
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
defaults:
run:
working-directory: libs/cli
@@ -58,7 +60,7 @@ jobs:
run: |
langgraph build -t ${{ matrix.example.tag }}
- name: Test service ${{ matrix.example.name }}
if: ${{ steps.changed-files.outputs.all && secrets.LANGSMITH_API_KEY != '' }}
if: ${{ steps.changed-files.outputs.all && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: ${{ matrix.example.workdir }}
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
@@ -89,7 +91,7 @@ jobs:
run: |
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
- name: Test Python monorepo service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && secrets.LANGSMITH_API_KEY != '' }}
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/python-monorepo-example
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
@@ -104,7 +106,7 @@ jobs:
run: |
langgraph build -t langgraph-test-h
- name: Test prerelease reqs service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && secrets.LANGSMITH_API_KEY != '' }}
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
-331
View File
@@ -1,331 +0,0 @@
---
name: CLI Release
on:
push:
tags:
- 'cli-v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g., cli-v0.2.10)'
required: true
permissions:
contents: write
id-token: write # OIDC trusted publishing for PyPI
concurrency:
group: cli-release
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
# ──────────────────────────────────────────────
# Build native binaries for each platform
# ──────────────────────────────────────────────
build:
name: Build - ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
defaults:
run:
working-directory: libs/cli
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: langgraph
npm_pkg: langgraph-cli-linux-x64
- name: linux-arm64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
binary: langgraph
npm_pkg: langgraph-cli-linux-arm64
- name: darwin-x64
runner: macos-13 # Intel
target: x86_64-apple-darwin
binary: langgraph
npm_pkg: langgraph-cli-darwin-x64
- name: darwin-arm64
runner: macos-latest # Apple Silicon
target: aarch64-apple-darwin
binary: langgraph
npm_pkg: langgraph-cli-darwin-arm64
- name: win32-x64
runner: windows-latest
target: x86_64-pc-windows-msvc
binary: langgraph.exe
npm_pkg: langgraph-cli-win32-x64
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: libs/cli
key: ${{ matrix.target }}
- name: Build release binary
run: cargo build --release --target ${{ matrix.target }}
- name: Run tests
if: matrix.name != 'linux-arm64' # skip on ARM (slow QEMU)
run: cargo test --release --target ${{ matrix.target }}
# Upload raw binary for GitHub release
- name: Prepare binary artifact
shell: bash
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/${{ matrix.binary }} dist/
cd dist
if [[ "${{ matrix.name }}" == win32-* ]]; then
7z a ../langgraph-${{ matrix.name }}.zip ${{ matrix.binary }}
else
tar czf ../langgraph-${{ matrix.name }}.tar.gz ${{ matrix.binary }}
fi
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.name }}
path: |
libs/cli/langgraph-${{ matrix.name }}.tar.gz
libs/cli/langgraph-${{ matrix.name }}.zip
if-no-files-found: ignore
# Prepare npm platform package
- name: Prepare npm platform package
shell: bash
run: |
cp target/${{ matrix.target }}/release/${{ matrix.binary }} npm/${{ matrix.npm_pkg }}/bin/
- name: Upload npm platform package
uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.name }}
path: libs/cli/npm/${{ matrix.npm_pkg }}/
# ──────────────────────────────────────────────
# Build Linux musl (Alpine-compatible) binary
# ──────────────────────────────────────────────
build-musl:
name: Build - linux-x64-musl
runs-on: ubuntu-latest
defaults:
run:
working-directory: libs/cli
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- name: Install musl tools
run: sudo apt-get update && sudo apt-get install -y musl-tools
- uses: Swatinem/rust-cache@v2
with:
workspaces: libs/cli
key: x86_64-unknown-linux-musl
- name: Build release binary
run: cargo build --release --target x86_64-unknown-linux-musl
- name: Prepare binary artifact
run: |
mkdir -p dist
cp target/x86_64-unknown-linux-musl/release/langgraph dist/
cd dist && tar czf ../langgraph-linux-x64-musl.tar.gz langgraph
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: binary-linux-x64-musl
path: libs/cli/langgraph-linux-x64-musl.tar.gz
# ──────────────────────────────────────────────
# Build PyPI wheels via maturin
# ──────────────────────────────────────────────
pypi-wheels:
name: PyPI wheel - ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
manylinux: manylinux_2_17
- name: linux-arm64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
manylinux: manylinux_2_17
- name: linux-x64-musl
runner: ubuntu-latest
target: x86_64-unknown-linux-musl
manylinux: musllinux_1_2
- name: darwin-x64
runner: macos-13
target: x86_64-apple-darwin
manylinux: auto
- name: darwin-arm64
runner: macos-latest
target: aarch64-apple-darwin
manylinux: auto
- name: win32-x64
runner: windows-latest
target: x86_64-pc-windows-msvc
manylinux: auto
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build wheel
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux }}
args: --release --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
rust-toolchain: stable
- name: Upload wheel
uses: actions/upload-artifact@v4
with:
name: wheel-${{ matrix.name }}
path: libs/cli/dist/*.whl
# ──────────────────────────────────────────────
# Build sdist
# ──────────────────────────────────────────────
pypi-sdist:
name: PyPI sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
- name: Upload sdist
uses: actions/upload-artifact@v4
with:
name: sdist
path: libs/cli/dist/*.tar.gz
# ──────────────────────────────────────────────
# Publish to PyPI
# ──────────────────────────────────────────────
pypi-publish:
name: Publish to PyPI
needs: [pypi-wheels, pypi-sdist]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/cli-v')
environment:
name: pypi
url: https://pypi.org/project/langgraph-cli/
steps:
- uses: actions/download-artifact@v4
with:
pattern: wheel-*
path: dist
merge-multiple: true
- uses: actions/download-artifact@v4
with:
name: sdist
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# ──────────────────────────────────────────────
# Publish to npm
# ──────────────────────────────────────────────
npm-publish:
name: Publish to npm
needs: [build]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/cli-v')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
# Download all npm platform packages
- uses: actions/download-artifact@v4
with:
pattern: npm-*
path: npm-artifacts
# Publish each platform package
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
for dir in npm-artifacts/npm-*/; do
echo "Publishing $(basename $dir)..."
cd "$dir"
npm publish --access public
cd -
done
# Publish the main package
- name: Publish main package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
cd libs/cli/npm/langgraph-cli
npm publish --access public
# ──────────────────────────────────────────────
# Create GitHub Release with standalone binaries
# ──────────────────────────────────────────────
github-release:
name: GitHub Release
needs: [build, build-musl]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/cli-v')
steps:
- uses: actions/download-artifact@v4
with:
pattern: binary-*
path: release-artifacts
merge-multiple: true
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: release-artifacts/*
generate_release_notes: true
draft: false
-49
View File
@@ -1,49 +0,0 @@
---
name: CLI Rust
on:
push:
branches:
- main
paths:
- 'libs/cli/src/**'
- 'libs/cli/Cargo.toml'
- 'libs/cli/Cargo.lock'
- 'libs/cli/build.rs'
pull_request:
paths:
- 'libs/cli/src/**'
- 'libs/cli/Cargo.toml'
- 'libs/cli/Cargo.lock'
- 'libs/cli/build.rs'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Check / Lint / Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: libs/cli
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: libs/cli
- name: Format check
run: cargo fmt --check
- name: Clippy
run: cargo clippy -- -D warnings
- name: Test
run: cargo test
- name: Build release
run: cargo build --release
+49
View File
@@ -0,0 +1,49 @@
name: Deploy Redirects to GitHub Pages
on:
push:
branches:
- main
paths:
- 'docs/**'
- '.github/workflows/deploy-redirects.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Generate redirect files
run: python docs/generate_redirects.py
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'docs/_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+1
View File
@@ -0,0 +1 @@
_site/
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
Generate HTML redirect files from redirects.json.
Usage:
python generate_redirects.py
This script reads redirects.json and generates individual HTML files
for each redirect path. Each HTML file uses meta refresh (0 delay)
which is SEO-friendly and treated similarly to 301 redirects by Google.
To add new redirects, simply edit redirects.json and re-run this script.
"""
import json
import os
from pathlib import Path
# Default fallback URL for any path not in the redirect map
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting...</title>
<link rel="canonical" href="{url}">
<meta name="robots" content="noindex">
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
<meta http-equiv="refresh" content="0; url={url}">
</head>
<body>
Redirecting...
</body>
</html>
"""
ROOT_HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting to LangGraph Documentation</title>
<link rel="canonical" href="{url}">
<meta name="robots" content="noindex">
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
<meta http-equiv="refresh" content="0; url={url}">
</head>
<body>
<h1>Documentation has moved</h1>
<p>The LangGraph documentation has moved to <a href="{url}">docs.langchain.com</a>.</p>
<p>Redirecting you now...</p>
</body>
</html>
"""
CATCHALL_404_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting to LangGraph Documentation</title>
<link rel="canonical" href="{default_url}">
<meta name="robots" content="noindex">
<script>
// Catchall redirect for any unmapped paths
window.location.replace("{default_url}");
</script>
<meta http-equiv="refresh" content="0; url={default_url}">
</head>
<body>
<h1>Documentation has moved</h1>
<p>The LangGraph documentation has moved to <a href="{default_url}">docs.langchain.com</a>.</p>
<p>Redirecting you now...</p>
</body>
</html>
"""
def generate_redirects():
script_dir = Path(__file__).parent
output_dir = script_dir / "_site"
# Load redirects
with open(script_dir / "redirects.json") as f:
redirects = json.load(f)
# Clean output directory
if output_dir.exists():
import shutil
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True)
# Generate individual HTML files for each redirect
for old_path, new_url in redirects.items():
# Remove leading slash and create directory structure
path = old_path.lstrip("/")
# Check if path has a file extension (e.g., .txt, .xml)
# If so, create the file directly instead of a directory with index.html
path_obj = Path(path)
has_extension = path_obj.suffix and len(path_obj.suffix) <= 5
if not path:
html_path = output_dir / "index.html"
elif has_extension:
# For files with extensions, create the file directly
html_path = output_dir / path
else:
# For directory-style URLs, create index.html inside
html_path = output_dir / path / "index.html"
# Create parent directories
html_path.parent.mkdir(parents=True, exist_ok=True)
# Write the redirect HTML
html_path.write_text(HTML_TEMPLATE.format(url=new_url))
print(f"Created: {html_path}")
# Create root index.html
root_index = output_dir / "index.html"
if not root_index.exists():
root_index.write_text(ROOT_HTML_TEMPLATE.format(url=DEFAULT_REDIRECT))
print(f"Created: {root_index}")
# Create 404.html for catchall
catchall_404 = output_dir / "404.html"
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
print(f"Created: {catchall_404}")
# Copy static files (like llms.txt) that can't be redirected via HTML
static_files = ["llms.txt"]
for static_file in static_files:
src = script_dir / static_file
if src.exists():
dst = output_dir / static_file
dst.write_text(src.read_text())
print(f"Copied: {dst}")
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
if __name__ == "__main__":
generate_redirects()
+35
View File
@@ -0,0 +1,35 @@
# LangGraph
LangGraph documentation has moved to docs.langchain.com.
## Overview
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
## Core Concepts
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
## How-To Guides
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
## Tutorials
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
## Reference
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
## LangGraph Platform
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
+296
View File
@@ -0,0 +1,296 @@
{
"/how-tos/stream-values": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/stream-updates": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/streaming-content": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/stream-multiple": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/streaming-tokens-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/streaming-from-final-node": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/streaming-events-from-within-tools-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/state-reducers": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state",
"/how-tos/sequence": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps",
"/how-tos/branching": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches",
"/how-tos/recursion-limit": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops",
"/how-tos/visualization": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph",
"/how-tos/input_output_schema": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas",
"/how-tos/pass_private_state": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes",
"/how-tos/state-model": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state",
"/how-tos/map-reduce": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api",
"/how-tos/command": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command",
"/how-tos/configuration": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration",
"/how-tos/node-retries": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies",
"/how-tos/return-when-recursion-limit-hits": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit",
"/how-tos/async": "https://docs.langchain.com/oss/python/langgraph/graph-api#async",
"/how-tos/memory/manage-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/memory/delete-messages": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages",
"/how-tos/memory/add-summary-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages",
"/how-tos/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/agents/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/subgraph-transform-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas",
"/how-tos/subgraphs-manage-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence",
"/how-tos/persistence_postgres": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
"/how-tos/persistence_mongodb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
"/how-tos/persistence_redis": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
"/how-tos/subgraph-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs",
"/how-tos/cross-thread-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
"/cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads",
"/cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads",
"/cloud/concepts/threads": "https://docs.langchain.com/oss/python/langgraph/persistence#threads",
"/how-tos/persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/tool-calling-errors": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/how-tos/pass-config-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/how-tos/pass-run-time-values-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/how-tos/update-state-from-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/agents/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/how-tos/agent-handoffs": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/how-tos/multi-agent-network": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/how-tos/multi-agent-multi-turn-convo": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/cloud/index": "https://docs.langchain.com/oss/python/langgraph/overview",
"/cloud/how-tos/index": "https://docs.langchain.com/langsmith/home",
"/cloud/concepts/api": "https://docs.langchain.com/langsmith/agent-server",
"/cloud/concepts/cloud": "https://docs.langchain.com/langsmith/cloud",
"/cloud/faq/studio": "https://docs.langchain.com/langsmith/studio",
"/cloud/how-tos/human_in_the_loop_edit_state": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
"/cloud/how-tos/human_in_the_loop_user_input": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
"/concepts/platform_architecture": "https://docs.langchain.com/langsmith/cloud#architecture",
"/cloud/how-tos/stream_values": "https://docs.langchain.com/langsmith/streaming",
"/cloud/how-tos/stream_updates": "https://docs.langchain.com/langsmith/streaming",
"/cloud/how-tos/stream_messages": "https://docs.langchain.com/langsmith/streaming",
"/cloud/how-tos/stream_events": "https://docs.langchain.com/langsmith/streaming",
"/cloud/how-tos/stream_debug": "https://docs.langchain.com/langsmith/streaming",
"/cloud/how-tos/stream_multiple": "https://docs.langchain.com/langsmith/streaming",
"/cloud/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/agents/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/create-react-agent": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration",
"/how-tos/create-react-agent-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/create-react-agent-system-prompt": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/create-react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
"/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
"/reference/prebuilt": "https://reference.langchain.com/python/langgraph/agents/",
"/concepts/high_level": "https://docs.langchain.com/oss/python/langgraph/overview",
"/concepts/index": "https://docs.langchain.com/oss/python/langgraph/overview",
"/concepts/v0-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/how-tos/index": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/introduction": "https://docs.langchain.com/oss/python/langgraph/overview",
"/agents/deployment": "https://docs.langchain.com/oss/python/langgraph/local-server",
"/how-tos/deploy-self-hosted": "https://docs.langchain.com/langsmith/platform-setup",
"/concepts/self_hosted": "https://docs.langchain.com/langsmith/platform-setup",
"/tutorials/deployment": "https://docs.langchain.com/langsmith/deployments",
"/cloud/how-tos/assistant_versioning": "https://docs.langchain.com/langsmith/configuration-cloud",
"/cloud/concepts/runs": "https://docs.langchain.com/langsmith/assistants#execution",
"/how-tos/wait-user-input-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"/how-tos/review-tool-calls-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"/how-tos/create-react-agent-hitl": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/agents/human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/how-tos/human_in_the_loop/dynamic_breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/concepts/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/how-tos/human_in_the_loop/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/cloud/how-tos/human_in_the_loop_breakpoint": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
"/how-tos/human_in_the_loop/edit-graph-state": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
"/examples/index": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"/guides/index": "https://docs.langchain.com/oss/python/langchain/overview",
"/tutorials/index": "https://docs.langchain.com/oss/python/learn",
"/llms-txt-overview": "https://docs.langchain.com/llms.txt",
"/tutorials/rag/langgraph_adaptive_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/multi_agent/multi-agent-collaboration": "https://docs.langchain.com/oss/python/langchain/multi-agent",
"/how-tos/create-react-agent-manage-message-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/many-tools": "https://docs.langchain.com/oss/python/langchain/tools",
"/tutorials/customer-support/customer-support": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/how-tos/react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
"/tutorials/code_assistant/langgraph_code_assistant": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/multi_agent/hierarchical_agent_teams": "https://docs.langchain.com/oss/python/langchain/supervisor",
"/tutorials/auth/getting_started": "https://docs.langchain.com/langsmith/auth",
"/tutorials/auth/resource_auth": "https://docs.langchain.com/langsmith/resource-auth",
"/tutorials/auth/add_auth_server": "https://docs.langchain.com/langsmith/add-auth-server",
"/how-tos/use-remote-graph": "https://docs.langchain.com/langsmith/use-remote-graph",
"/how-tos/autogen-integration": "https://docs.langchain.com/langsmith/autogen-integration",
"/how-tos/human_in_the_loop/wait-user-input": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/cloud/how-tos/use_stream_react": "https://docs.langchain.com/langsmith/use-stream-react",
"/cloud/how-tos/generative_ui_react": "https://docs.langchain.com/langsmith/generative-ui-react",
"/concepts/langgraph_platform": "https://docs.langchain.com/langsmith/home",
"/concepts/langgraph_components": "https://docs.langchain.com/langsmith/components",
"/concepts/langgraph_server": "https://docs.langchain.com/langsmith/agent-server",
"/concepts/langgraph_data_plane": "https://docs.langchain.com/langsmith/data-plane",
"/concepts/langgraph_control_plane": "https://docs.langchain.com/langsmith/control-plane",
"/concepts/langgraph_cli": "https://docs.langchain.com/langsmith/cli",
"/concepts/langgraph_studio": "https://docs.langchain.com/langsmith/studio",
"/cloud/how-tos/studio/quick_start": "https://docs.langchain.com/langsmith/quick-start-studio",
"/cloud/how-tos/invoke_studio": "https://docs.langchain.com/langsmith/use-studio",
"/cloud/how-tos/studio/manage_assistants": "https://docs.langchain.com/langsmith/use-studio",
"/cloud/how-tos/threads_studio": "https://docs.langchain.com/langsmith/use-threads",
"/cloud/how-tos/iterate_graph_studio": "https://docs.langchain.com/langsmith/use-studio",
"/cloud/how-tos/studio/run_evals": "https://docs.langchain.com/langsmith/observability",
"/cloud/how-tos/clone_traces_studio": "https://docs.langchain.com/langsmith/observability",
"/cloud/how-tos/datasets_studio": "https://docs.langchain.com/langsmith/use-studio",
"/concepts/sdk": "https://docs.langchain.com/langsmith/sdk",
"/concepts/plans": "https://docs.langchain.com/langsmith/home",
"/concepts/application_structure": "https://docs.langchain.com/langsmith/application-structure",
"/concepts/scalability_and_resilience": "https://docs.langchain.com/langsmith/scalability-and-resilience",
"/concepts/auth": "https://docs.langchain.com/langsmith/auth",
"/how-tos/auth/custom_auth": "https://docs.langchain.com/langsmith/custom-auth",
"/how-tos/auth/openapi_security": "https://docs.langchain.com/langsmith/openapi-security",
"/concepts/assistants": "https://docs.langchain.com/langsmith/assistants",
"/cloud/how-tos/configuration_cloud": "https://docs.langchain.com/langsmith/configuration-cloud",
"/cloud/how-tos/use_threads": "https://docs.langchain.com/langsmith/use-threads",
"/cloud/how-tos/background_run": "https://docs.langchain.com/langsmith/background-run",
"/cloud/how-tos/same-thread": "https://docs.langchain.com/langsmith/same-thread",
"/cloud/how-tos/stateless_runs": "https://docs.langchain.com/langsmith/stateless-runs",
"/cloud/how-tos/configurable_headers": "https://docs.langchain.com/langsmith/configurable-headers",
"/concepts/double_texting": "https://docs.langchain.com/langsmith/double-texting",
"/cloud/how-tos/interrupt_concurrent": "https://docs.langchain.com/langsmith/interrupt-concurrent",
"/cloud/how-tos/rollback_concurrent": "https://docs.langchain.com/langsmith/rollback-concurrent",
"/cloud/how-tos/reject_concurrent": "https://docs.langchain.com/langsmith/reject-concurrent",
"/cloud/how-tos/enqueue_concurrent": "https://docs.langchain.com/langsmith/enqueue-concurrent",
"/cloud/concepts/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
"/cloud/how-tos/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
"/cloud/concepts/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
"/cloud/how-tos/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
"/how-tos/http/custom_lifespan": "https://docs.langchain.com/langsmith/custom-lifespan",
"/how-tos/http/custom_middleware": "https://docs.langchain.com/langsmith/custom-middleware",
"/how-tos/http/custom_routes": "https://docs.langchain.com/langsmith/custom-routes",
"/cloud/concepts/data_storage_and_privacy": "https://docs.langchain.com/langsmith/data-storage-and-privacy",
"/cloud/deployment/semantic_search": "https://docs.langchain.com/langsmith/semantic-search",
"/how-tos/ttl/configure_ttl": "https://docs.langchain.com/langsmith/configure-ttl",
"/concepts/deployment_options": "https://docs.langchain.com/langsmith/deployments",
"/cloud/quick_start": "https://docs.langchain.com/langsmith/deployment-quickstart",
"/cloud/deployment/setup": "https://docs.langchain.com/langsmith/setup-app-requirements-txt",
"/cloud/deployment/setup_pyproject": "https://docs.langchain.com/langsmith/setup-pyproject",
"/cloud/deployment/setup_javascript": "https://docs.langchain.com/langsmith/setup-javascript",
"/cloud/deployment/custom_docker": "https://docs.langchain.com/langsmith/custom-docker",
"/cloud/deployment/graph_rebuild": "https://docs.langchain.com/langsmith/graph-rebuild",
"/concepts/langgraph_cloud": "https://docs.langchain.com/langsmith/cloud",
"/concepts/langgraph_self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
"/concepts/langgraph_self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
"/concepts/langgraph_standalone_container": "https://docs.langchain.com/langsmith/docker",
"/cloud/deployment/cloud": "https://docs.langchain.com/langsmith/cloud",
"/cloud/deployment/self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
"/cloud/deployment/self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
"/cloud/deployment/standalone_container": "https://docs.langchain.com/langsmith/docker",
"/concepts/server-mcp": "https://docs.langchain.com/langsmith/server-mcp",
"/cloud/how-tos/human_in_the_loop_time_travel": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
"/cloud/how-tos/add-human-in-the-loop": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
"/cloud/deployment/egress": "https://docs.langchain.com/langsmith/env-var",
"/cloud/how-tos/streaming": "https://docs.langchain.com/langsmith/streaming",
"/cloud/reference/api/api_ref": "https://docs.langchain.com/langsmith/server-api-ref",
"/cloud/reference/langgraph_server_changelog": "https://docs.langchain.com/langsmith/agent-server-changelog",
"/cloud/reference/api/api_ref_control_plane": "https://docs.langchain.com/langsmith/api-ref-control-plane",
"/cloud/reference/cli": "https://docs.langchain.com/langsmith/cli",
"/cloud/reference/env_var": "https://docs.langchain.com/langsmith/env-var",
"/troubleshooting/studio": "https://docs.langchain.com/langsmith/troubleshooting-studio",
"/index": "https://docs.langchain.com/oss/python/langgraph/overview",
"/agents/agents": "https://docs.langchain.com/oss/python/langchain/agents",
"/concepts/why-langgraph": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/get-started/1-build-basic-chatbot": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/tutorials/get-started/2-add-tools": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/tutorials/get-started/3-add-memory": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/tutorials/get-started/4-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/tutorials/get-started/5-customize-state": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/tutorials/get-started/6-time-travel": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/tutorials/langsmith/local-server": "https://docs.langchain.com/oss/python/langgraph/local-server",
"/tutorials/workflows": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/tutorials/plan-and-execute/plan-and-execute": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list",
"/tutorials/langgraph-platform/local-server/local-server": "https://docs.langchain.com/langsmith/local-server",
"/concepts/agentic_concepts": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/agents/overview": "https://docs.langchain.com/oss/python/langchain/agents",
"/agents/run_agents": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"/concepts/low_level": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/how-tos/graph-api": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/how-tos/react-agent-from-scratch": "https://docs.langchain.com/oss/python/langchain/quickstart",
"/concepts/functional_api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"/how-tos/use-functional-api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"/concepts/pregel": "https://docs.langchain.com/oss/python/langgraph/pregel",
"/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/concepts/persistence": "https://docs.langchain.com/oss/python/langgraph/persistence",
"/concepts/durable_execution": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
"/concepts/memory": "https://docs.langchain.com/oss/python/langgraph/memory",
"/how-tos/memory/add-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/agents/context": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/agents/models": "https://docs.langchain.com/oss/python/langgraph/overview",
"/concepts/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/how-tos/tool-calling": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/concepts/human_in_the_loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/how-tos/human_in_the_loop/add-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"/concepts/time-travel": "https://docs.langchain.com/oss/python/langgraph/persistence",
"/how-tos/human_in_the_loop/time-travel": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
"/concepts/subgraphs": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
"/how-tos/subgraph": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
"/concepts/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/agents/multi-agent": "https://docs.langchain.com/oss/python/langchain/multi-agent",
"/how-tos/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/concepts/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
"/agents/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
"/concepts/tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
"/how-tos/enable-tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
"/agents/evals": "https://docs.langchain.com/oss/python/langgraph/overview",
"/concepts/template_applications": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/rag/langgraph_agentic_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/multi_agent/agent_supervisor": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/tutorials/sql/sql-agent": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
"/agents/ui": "https://docs.langchain.com/oss/python/langgraph/ui",
"/how-tos/run-id-langsmith": "https://docs.langchain.com/oss/python/langgraph/observability",
"/troubleshooting/errors/index": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"/troubleshooting/errors/INVALID_CHAT_HISTORY": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
"/troubleshooting/errors/INVALID_LICENSE": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"/adopters": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"/concepts/faq": "https://docs.langchain.com/oss/python/langgraph/overview",
"/agents/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
"/reference/index": "https://reference.langchain.com/python/langgraph/",
"/reference/graphs": "https://reference.langchain.com/python/langgraph/graphs/",
"/reference/func": "https://reference.langchain.com/python/langgraph/func/",
"/reference/pregel": "https://reference.langchain.com/python/langgraph/pregel/",
"/reference/checkpoints": "https://reference.langchain.com/python/langgraph/checkpoints/",
"/reference/store": "https://reference.langchain.com/python/langgraph/store/",
"/reference/cache": "https://reference.langchain.com/python/langgraph/cache/",
"/reference/types": "https://reference.langchain.com/python/langgraph/types/",
"/reference/runtime": "https://reference.langchain.com/python/langgraph/runtime/",
"/reference/config": "https://reference.langchain.com/python/langgraph/config/",
"/reference/errors": "https://reference.langchain.com/python/langgraph/errors/",
"/reference/constants": "https://reference.langchain.com/python/langgraph/constants/",
"/reference/channels": "https://reference.langchain.com/python/langgraph/channels/",
"/reference/agents": "https://reference.langchain.com/python/langgraph/agents/",
"/reference/supervisor": "https://reference.langchain.com/python/langgraph/supervisor/",
"/reference/swarm": "https://reference.langchain.com/python/langgraph/swarm/",
"/reference/mcp": "https://reference.langchain.com/python/langgraph/mcp/",
"/cloud/reference/sdk/python_sdk_ref": "https://reference.langchain.com/python/langsmith/deployment/sdk/",
"/reference/remote_graph": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/",
"/additional-resources/index": "https://docs.langchain.com/oss/python/langchain/overview",
"/cloud/reference/sdk/js_ts_sdk_ref": "https://reference.langchain.com/javascript/modules/langsmith.html",
"/snippets/chat_model_tabs": "https://docs.langchain.com/oss/python/langchain/overview",
"/troubleshooting/errors/GRAPH_RECURSION_LIMIT": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
"/troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
"/troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
"/troubleshooting/errors/MULTIPLE_SUBGRAPHS": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
"/tutorials/rag/langgraph_self_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/additional-resources": "https://docs.langchain.com/oss/python/langgraph/overview",
"/examples": "https://docs.langchain.com/oss/python/langgraph/overview",
"/guides": "https://docs.langchain.com/oss/python/langgraph/overview",
"/how-tos/autogen-integration-functional": "https://docs.langchain.com/oss/python/langgraph/overview",
"/how-tos/cross-thread-persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
"/how-tos/disable-streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
"/how-tos/memory/semantic-search": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/multi-agent-multi-turn-convo-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/how-tos/multi-agent-network-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"/how-tos/persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"/how-tos/react-agent-from-scratch-functional": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"/reference": "https://reference.langchain.com/python/langgraph/",
"/troubleshooting/errors": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/chatbots/information-gather-prompting": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/extraction/retries": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/langgraph-platform/local-server": "https://docs.langchain.com/langsmith/agent-server",
"/tutorials/lats/lats": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/llm-compiler/LLMCompiler": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/rag/langgraph_adaptive_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/rag/langgraph_crag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/rag/langgraph_crag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/rag/langgraph_self_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"/tutorials/reflection/reflection": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/reflexion/reflexion": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/rewoo/rewoo": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/self-discover/self-discover": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/tnt-llm/tnt-llm": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/tot/tot": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/usaco/usaco": "https://docs.langchain.com/oss/python/langgraph/overview",
"/tutorials/web-navigation/web_voyager": "https://docs.langchain.com/oss/python/langgraph/overview"
}
@@ -57,3 +57,9 @@ lint.select = [
]
lint.ignore = ["E501", "B008"]
target-version = "py310"
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
+1 -1
View File
@@ -13,7 +13,7 @@ license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=2.1.2,<5.0.0",
"orjson>=3.10.1",
"orjson>=3.11.5",
"psycopg>=3.2.0",
"psycopg-pool>=3.2.0",
]
+1 -1
View File
@@ -346,7 +346,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "orjson", specifier = ">=3.10.1" },
{ name = "orjson", specifier = ">=3.11.5" },
{ name = "psycopg", specifier = ">=3.2.0" },
{ name = "psycopg-pool", specifier = ">=3.2.0" },
]
+6 -6
View File
@@ -267,7 +267,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.12"
version = "1.2.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -279,9 +279,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/1d/08e935d1532fcc90981f6e5bb6825914c9227ea7a962c62b1e18619b49e7/langchain_core-1.2.12.tar.gz", hash = "sha256:4d7fa6643d7ab06fc1905a9b7dcbe96a6f3c181046b56edf9c0c17ecd412d9e9", size = 831329, upload-time = "2026-02-12T20:53:15.01Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/a5/678ab0e5cc57794f20ae5ed12c1442506ef1108c9434f950aebc6044e5a3/langchain_core-1.2.12-py3-none-any.whl", hash = "sha256:66ca17a2a9cb007ab29021968e6adfcf4228067151dc2bd6ebfff265ffaf92f5", size = 500132, upload-time = "2026-02-12T20:53:13.806Z" },
{ url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
]
[[package]]
@@ -1198,14 +1198,14 @@ wheels = [
[[package]]
name = "redis"
version = "7.1.1"
version = "7.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
]
[[package]]
-9
View File
@@ -1,10 +1 @@
.langgraph_api/
npm/
# Rust build artifacts
/target/
/dist/
# npm platform binaries (added at build time, not checked in)
npm/*/bin/langgraph
npm/*/bin/langgraph.exe
-2521
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
[package]
name = "langgraph-cli"
version = "0.2.10"
edition = "2021"
description = "Native CLI for LangGraph"
license = "MIT"
[[bin]]
name = "langgraph"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], default-features = false }
zip = "2"
indexmap = { version = "2", features = ["serde"] }
open = "5"
indicatif = "0.17"
console = "0.15"
dotenvy = "0.15"
tempfile = "3"
thiserror = "2"
dialoguer = "0.11"
regex = "1"
which = "7"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
+5 -52
View File
@@ -1,27 +1,7 @@
.PHONY: test lint format test-integration update-schema build build-release clean cargo-test cargo-lint
.PHONY: test lint format test-integration update-schema bump-version
######################
# RUST BUILD
######################
build:
cargo build
build-release:
cargo build --release
cargo-test:
cargo test
cargo-lint:
cargo fmt --check
cargo clippy -- -D warnings
cargo-fmt:
cargo fmt
######################
# PYTHON TESTING AND COVERAGE
# TESTING AND COVERAGE
######################
TEST?= "tests/unit_tests"
@@ -31,7 +11,7 @@ test-integration:
uv run pytest tests/integration_tests
######################
# PYTHON LINTING AND FORMATTING
# LINTING AND FORMATTING
######################
# Define a variable for Python and notebook files.
@@ -53,35 +33,8 @@ format format_diff:
uv run ruff format $(PYTHON_FILES)
uv run ruff check --select I --fix $(PYTHON_FILES)
######################
# PACKAGING
######################
# Build maturin wheel for current platform (development)
wheel-dev:
maturin build --release
# Build maturin wheel for specific target
# Usage: make wheel TARGET=x86_64-unknown-linux-gnu
TARGET?=
wheel:
maturin build --release $(if $(TARGET),--target $(TARGET),)
# Build sdist
sdist:
maturin sdist
######################
# SCHEMA
######################
update-schema:
uv run python generate_schema.py
######################
# CLEANUP
######################
clean:
cargo clean
rm -rf dist/ target/wheels/
bump-version:
uv run hatch version patch
-3
View File
@@ -1,3 +0,0 @@
fn main() {
// Cargo already sets CARGO_PKG_VERSION, nothing to do
}
+2 -2
View File
@@ -29,8 +29,8 @@
"@eslint/js": "^10.0.1",
"@tsconfig/recommended": "^1.0.13",
"@types/jest": "^30.0.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"dotenv": "^17.3.1",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
+68 -68
View File
@@ -1079,101 +1079,101 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@^8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz#086d2ef661507b561f7b17f62d3179d692a0765f"
integrity sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==
"@typescript-eslint/eslint-plugin@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.55.0"
"@typescript-eslint/type-utils" "8.55.0"
"@typescript-eslint/utils" "8.55.0"
"@typescript-eslint/visitor-keys" "8.55.0"
"@typescript-eslint/scope-manager" "8.56.0"
"@typescript-eslint/type-utils" "8.56.0"
"@typescript-eslint/utils" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.4.0"
"@typescript-eslint/parser@^8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.55.0.tgz#6eace4e9e95f178d3447ed1f17f3d6a5dfdb345c"
integrity sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==
"@typescript-eslint/parser@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
dependencies:
"@typescript-eslint/scope-manager" "8.55.0"
"@typescript-eslint/types" "8.55.0"
"@typescript-eslint/typescript-estree" "8.55.0"
"@typescript-eslint/visitor-keys" "8.55.0"
"@typescript-eslint/scope-manager" "8.56.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/typescript-estree" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.55.0.tgz#b8a71c06a625bdad481c24d5614b68e252f3ae9b"
integrity sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==
"@typescript-eslint/project-service@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.55.0"
"@typescript-eslint/types" "^8.55.0"
"@typescript-eslint/tsconfig-utils" "^8.56.0"
"@typescript-eslint/types" "^8.56.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz#8a0752c31c788651840dc98f840b0c2ebe143b8c"
integrity sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==
"@typescript-eslint/scope-manager@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
dependencies:
"@typescript-eslint/types" "8.55.0"
"@typescript-eslint/visitor-keys" "8.55.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
"@typescript-eslint/tsconfig-utils@8.55.0", "@typescript-eslint/tsconfig-utils@^8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz#62f1d005419985e09d37a040b2f1450e4e805afa"
integrity sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==
"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
"@typescript-eslint/type-utils@8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz#195d854b3e56308ce475fdea2165313bb1190200"
integrity sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==
"@typescript-eslint/type-utils@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
dependencies:
"@typescript-eslint/types" "8.55.0"
"@typescript-eslint/typescript-estree" "8.55.0"
"@typescript-eslint/utils" "8.55.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/typescript-estree" "8.56.0"
"@typescript-eslint/utils" "8.56.0"
debug "^4.4.3"
ts-api-utils "^2.4.0"
"@typescript-eslint/types@8.55.0", "@typescript-eslint/types@^8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.55.0.tgz#8449c5a7adac61184cac92dbf6315733569708c2"
integrity sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==
"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
"@typescript-eslint/typescript-estree@8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz#c83ac92c11ce79bedd984937c7780a65e7f7b2e3"
integrity sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==
"@typescript-eslint/typescript-estree@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
dependencies:
"@typescript-eslint/project-service" "8.55.0"
"@typescript-eslint/tsconfig-utils" "8.55.0"
"@typescript-eslint/types" "8.55.0"
"@typescript-eslint/visitor-keys" "8.55.0"
"@typescript-eslint/project-service" "8.56.0"
"@typescript-eslint/tsconfig-utils" "8.56.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/visitor-keys" "8.56.0"
debug "^4.4.3"
minimatch "^9.0.5"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.4.0"
"@typescript-eslint/utils@8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.55.0.tgz#c1744d94a3901deb01f58b09d3478d811f96d619"
integrity sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==
"@typescript-eslint/utils@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.55.0"
"@typescript-eslint/types" "8.55.0"
"@typescript-eslint/typescript-estree" "8.55.0"
"@typescript-eslint/scope-manager" "8.56.0"
"@typescript-eslint/types" "8.56.0"
"@typescript-eslint/typescript-estree" "8.56.0"
"@typescript-eslint/visitor-keys@8.55.0":
version "8.55.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz#3d9a40fd4e3705c63d8fae3af58988add3ed464d"
integrity sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==
"@typescript-eslint/visitor-keys@8.56.0":
version "8.56.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
dependencies:
"@typescript-eslint/types" "8.55.0"
eslint-visitor-keys "^4.2.1"
"@typescript-eslint/types" "8.56.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.3.0":
version "1.3.0"
@@ -2241,7 +2241,7 @@ eslint-visitor-keys@^3.4.3:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint-visitor-keys@^4.0.0, eslint-visitor-keys@^4.2.1:
eslint-visitor-keys@^4.0.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
@@ -3533,9 +3533,9 @@ js-tokens@^4.0.0:
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^3.13.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
version "3.14.2"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0"
integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==
dependencies:
argparse "^1.0.7"
esprima "^4.0.0"
+47
View File
@@ -678,6 +678,51 @@ def _update_encryption_path(
)
def _update_checkpointer_path(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
"""Update checkpointer.path to use Docker container paths."""
checkpointer_conf = config.get("checkpointer")
if not checkpointer_conf or not isinstance(checkpointer_conf, dict):
return
if not (path_str := checkpointer_conf.get("path")):
return
module_str, sep, attr_str = path_str.partition(":")
if not sep or not module_str.startswith("."):
return # Already validated or absolute path
resolved = config_path.parent / module_str
if not resolved.exists():
raise FileNotFoundError(
f"Checkpointer file not found: {resolved} (from {path_str})"
)
if not resolved.is_file():
raise IsADirectoryError(f"Checkpointer path must be a file: {resolved}")
# Check faux packages first (higher priority)
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_path):
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
checkpointer_conf["path"] = new_path
return
# Check real packages
for real_path in local_deps.real_pkgs:
if resolved.is_relative_to(real_path):
new_path = (
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
)
checkpointer_conf["path"] = new_path
return
raise ValueError(
f"Checkpointer file '{resolved}' not covered by dependencies.\n"
"Add its parent directory to the 'dependencies' array in your config.\n"
f"Current dependencies: {config['dependencies']}"
)
def _update_http_app_path(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
@@ -877,6 +922,8 @@ def python_config_to_docker(
_update_auth_path(config_path, config, local_deps)
# Rewrite encryption path, so it points to the correct location in the Docker container
_update_encryption_path(config_path, config, local_deps)
# Rewrite checkpointer path, so it points to the correct location in the Docker container
_update_checkpointer_path(config_path, config, local_deps)
# Rewrite HTTP app path, so it points to the correct location in the Docker container
_update_http_app_path(config_path, config, local_deps)
+20
View File
@@ -167,6 +167,26 @@ class CheckpointerConfig(TypedDict, total=False):
If omitted, no checkpointer is set up (the object store will still be present, however).
"""
path: str
"""Import path to an async context manager that yields a `BaseCheckpointSaver`
instance.
The referenced object should be an `@asynccontextmanager`-decorated function
so that the server can properly manage the checkpointer's lifecycle (e.g.
opening and closing connections).
Examples:
- "./my_checkpointer.py:create_checkpointer"
- "my_package.checkpointer:create_checkpointer"
When provided, this replaces the default checkpointer.
You can use the `langgraph-checkpoint-conformance` package
(https://pypi.org/project/langgraph-checkpoint-conformance/) to run simple
conformance tests against your custom checkpointer and catch
incompatibilities early.
"""
ttl: ThreadTTLConfig | None
"""Optional. Defines the TTL (time-to-live) behavior configuration.
+15 -8
View File
@@ -1,6 +1,6 @@
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "langgraph-cli"
@@ -11,8 +11,12 @@ requires-python = ">=3.10"
readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = []
dependencies = [
"click>=8.1.7",
"langgraph-sdk>=0.1.0 ; python_version >= '3.11'",
]
[tool.hatch.version]
path = "langgraph_cli/__init__.py"
[project.optional-dependencies]
inmem = [
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
@@ -26,10 +30,8 @@ Twitter = "https://x.com/LangChain"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
[tool.maturin]
bindings = "bin"
manifest-path = "Cargo.toml"
strip = true
[project.scripts]
langgraph = "langgraph_cli.cli:cli"
[dependency-groups]
test = [
@@ -47,11 +49,15 @@ lint = [
dev = [
{include-group = "test"},
{include-group = "lint"},
"hatch>=1.16.2",
]
[tool.uv]
default-groups = ['dev']
[tool.hatch.build.targets.wheel]
include = ["langgraph_cli"]
[tool.pytest.ini_options]
addopts = "--strict-markers --strict-config --durations=5 -vv"
asyncio_mode = "auto"
@@ -63,6 +69,7 @@ lint.select = [
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
"UP", # pyupgrade
]
lint.ignore = ["E501", "B008"]
target-version = "py310"
+4
View File
@@ -542,6 +542,10 @@
"description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
},
"serde": {
"anyOf": [
{
+4
View File
@@ -542,6 +542,10 @@
"description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
},
"serde": {
"anyOf": [
{
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Bumps the version across Cargo.toml and all npm package.json files.
# Usage: ./scripts/bump-version.sh 0.3.0
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <version>"
echo "Example: $0 0.3.0"
exit 1
fi
VERSION="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLI_DIR="$(dirname "$SCRIPT_DIR")"
echo "Bumping version to $VERSION"
# Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$CLI_DIR/Cargo.toml"
rm -f "$CLI_DIR/Cargo.toml.bak"
echo " Updated Cargo.toml"
# All npm package.json files
for pkg in "$CLI_DIR"/npm/*/package.json; do
# Update the package's own version
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$pkg"
rm -f "$pkg.bak"
# Update optionalDependencies versions (main package only)
if grep -q "optionalDependencies" "$pkg" 2>/dev/null; then
sed -i.bak "s/\"@langchain\/langgraph-cli-\([^\"]*\)\": \"[^\"]*\"/\"@langchain\/langgraph-cli-\1\": \"$VERSION\"/" "$pkg"
rm -f "$pkg.bak"
fi
echo " Updated $(basename "$(dirname "$pkg")")/package.json"
done
# Update Cargo.lock
cd "$CLI_DIR"
cargo update -p langgraph-cli 2>/dev/null || true
echo " Updated Cargo.lock"
echo "Done! Version is now $VERSION"
echo ""
echo "Next steps:"
echo " 1. git add -A && git commit -m 'chore(cli): bump version to $VERSION'"
echo " 2. git tag cli-v$VERSION"
echo " 3. git push origin main --tags"
-50
View File
@@ -1,50 +0,0 @@
use std::collections::HashMap;
use crate::constants::{SUPABASE_PUBLIC_API_KEY, SUPABASE_URL};
/// Fire-and-forget telemetry: log a CLI command invocation.
///
/// Spawns a background thread that POSTs anonymized usage data to Supabase.
/// Respects the `LANGGRAPH_CLI_NO_ANALYTICS` environment variable -- if set to "1",
/// no data is sent.
pub fn log_command(command: &str, params: &HashMap<String, String>) {
if std::env::var("LANGGRAPH_CLI_NO_ANALYTICS").as_deref() == Ok("1") {
return;
}
let os_name = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();
let cli_version = env!("CARGO_PKG_VERSION").to_string();
let command = command.to_string();
let params = params.clone();
std::thread::spawn(move || {
let data = serde_json::json!({
"os": os_name,
"os_version": arch,
"python_version": "rust",
"cli_version": cli_version,
"cli_command": command,
"params": params,
});
let url = format!("{SUPABASE_URL}/rest/v1/logs");
// Use a blocking reqwest client in this thread
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(_) => return,
};
let _ = client
.post(&url)
.header("Content-Type", "application/json")
.header("apikey", SUPABASE_PUBLIC_API_KEY)
.header("User-Agent", "Mozilla/5.0")
.body(data.to_string())
.send();
});
}
-116
View File
@@ -1,116 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use console::style;
use crate::analytics;
use crate::config::docker_tag::docker_tag;
use crate::config::validate_config_file;
use crate::docker::dockerfile::config_to_docker;
use crate::exec::{run_command, run_command_streaming};
use crate::progress::Progress;
use crate::util::warn_non_wolfi_distro;
/// Build a LangGraph API server Docker image.
#[allow(clippy::too_many_arguments)]
pub fn run(
config: &str,
tag: &str,
pull: bool,
base_image: Option<&str>,
api_version: Option<&str>,
install_command: Option<&str>,
build_command: Option<&str>,
docker_build_args: &[String],
) -> Result<(), String> {
// Fire-and-forget analytics
let mut params = HashMap::new();
params.insert("pull".to_string(), pull.to_string());
analytics::log_command("build", &params);
// Check docker is available
if which::which("docker").is_err() {
return Err("Docker not installed".to_string());
}
let progress = Progress::new("Pulling...");
// Validate config
let config_path = Path::new(config);
let mut config_json = validate_config_file(config_path)?;
let config_value = serde_json::to_value(&config_json).unwrap();
warn_non_wolfi_distro(&config_value);
// Pull latest images if requested
if pull {
let image_tag = docker_tag(&config_json, base_image, api_version);
run_command("docker", &["pull", &image_tag], None, true)?;
}
progress.set_message("Building...");
// Determine build context
let is_js_project =
config_json.node_version.is_some() && config_json.python_version.is_none();
// For JS projects with install/build commands, use CWD; otherwise use config parent
let build_context = if is_js_project && (build_command.is_some() || install_command.is_some()) {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
} else {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_string_lossy()
.to_string()
};
// Generate Dockerfile
let (dockerfile_content, additional_contexts) = config_to_docker(
config_path,
&mut config_json,
base_image,
api_version,
install_command,
build_command,
Some(&build_context),
false, // no variable escaping for docker build
)?;
// Build docker build args
let mut args: Vec<String> = vec![
"build".to_string(),
"-f".to_string(),
"-".to_string(), // Dockerfile from stdin
"-t".to_string(),
tag.to_string(),
];
// Add additional build contexts
for (name, path) in &additional_contexts {
args.push("--build-context".to_string());
args.push(format!("{name}={path}"));
}
// Add passthrough docker build args
for extra in docker_build_args {
args.push(extra.clone());
}
// Add build context as last arg
args.push(build_context);
progress.finish();
// Run docker build with streaming output
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run_command_streaming("docker", &args_refs, Some(&dockerfile_content), true)?;
eprintln!(
"{}",
style(format!("Successfully built image: {tag}")).green()
);
Ok(())
}
-196
View File
@@ -1,196 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use console::style;
use crate::analytics;
use crate::config::validate_config_file;
/// Find the Python interpreter, preferring python3 over python.
fn find_python() -> Result<String, String> {
for candidate in &["python3", "python"] {
if which::which(candidate).is_ok() {
return Ok(candidate.to_string());
}
}
Err(
"Python not found. The `langgraph dev` command requires Python >= 3.11 with \
langgraph-cli[inmem] installed.\n\
Install with: pip install -U \"langgraph-cli[inmem]\""
.to_string(),
)
}
/// Run the LangGraph API server in development mode (in-memory, via Python subprocess).
///
/// This passes the config as a JSON object via stdin to a Python bootstrap script,
/// avoiding any string interpolation into Python source code.
#[allow(clippy::too_many_arguments)]
pub fn run(
host: &str,
port: u16,
no_reload: bool,
config: &str,
n_jobs_per_worker: Option<u32>,
no_browser: bool,
debug_port: Option<u16>,
wait_for_client: bool,
studio_url: Option<&str>,
allow_blocking: bool,
tunnel: bool,
server_log_level: &str,
) -> Result<(), String> {
// Fire-and-forget analytics
let mut params = HashMap::new();
params.insert("no_reload".to_string(), no_reload.to_string());
params.insert("no_browser".to_string(), no_browser.to_string());
params.insert("allow_blocking".to_string(), allow_blocking.to_string());
params.insert("tunnel".to_string(), tunnel.to_string());
analytics::log_command("dev", &params);
// Validate config
let config_path = Path::new(config);
let config_json = validate_config_file(config_path)?;
// Check for node_version -- in-mem server doesn't support JS graphs
if config_json.node_version.is_some() {
return Err(
"In-mem server for JS graphs is not supported in this version of the LangGraph CLI. \
Please use `npx @langchain/langgraph-cli` instead."
.to_string(),
);
}
let python = find_python()?;
// Pre-check that langgraph_api is importable
let check = Command::new(&python)
.args(["-c", "from langgraph_api.cli import run_server"])
.output();
match check {
Ok(output) if !output.status.success() => {
return Err(
"Required package 'langgraph-api' is not installed.\n\
Please install it with:\n\n\
pip install -U \"langgraph-cli[inmem]\"\n\n\
Note: The in-mem server requires Python 3.11 or higher."
.to_string(),
);
}
Err(_) => {
return Err(format!(
"Failed to run {python}. The `langgraph dev` command requires Python >= 3.11 with \
langgraph-cli[inmem] installed.\n\
Install with: pip install -U \"langgraph-cli[inmem]\""
));
}
_ => {}
}
// Build a JSON config object to pass via stdin.
// This avoids interpolating user data into Python source code.
let dev_config = serde_json::json!({
"host": host,
"port": port,
"reload": !no_reload,
"open_browser": !no_browser,
"wait_for_client": wait_for_client,
"allow_blocking": allow_blocking,
"tunnel": tunnel,
"server_log_level": server_log_level,
"dependencies": config_json.dependencies,
"graphs": config_json.graphs,
"n_jobs_per_worker": n_jobs_per_worker,
"debug_port": debug_port,
"studio_url": studio_url,
"env": config_json.env,
"store": config_json.store,
"auth": config_json.auth,
"http": config_json.http,
"ui": config_json.ui,
"ui_config": config_json.ui_config,
"webhooks": config_json.webhooks,
});
// Python bootstrap: reads JSON from stdin, calls run_server
let python_code = r#"
import sys, os, json, pathlib
config = json.loads(sys.stdin.read())
cwd = os.getcwd()
sys.path.append(cwd)
for dep in config.get('dependencies', []):
dep_path = pathlib.Path(cwd) / dep
if dep_path.is_dir() and dep_path.exists():
sys.path.append(str(dep_path))
from langgraph_api.cli import run_server
run_server(
config['host'],
config['port'],
config['reload'],
config['graphs'],
n_jobs_per_worker=config.get('n_jobs_per_worker'),
open_browser=config['open_browser'],
debug_port=config.get('debug_port'),
env=config.get('env'),
store=config.get('store'),
wait_for_client=config['wait_for_client'],
auth=config.get('auth'),
http=config.get('http'),
ui=config.get('ui'),
ui_config=config.get('ui_config'),
webhooks=config.get('webhooks'),
studio_url=config.get('studio_url'),
allow_blocking=config['allow_blocking'],
tunnel=config['tunnel'],
server_level=config['server_log_level'],
)
"#;
eprintln!(
"{}",
style("Starting LangGraph API server in development mode...").green()
);
// Spawn Python subprocess with config on stdin
let mut child = Command::new(&python)
.arg("-c")
.arg(python_code)
.current_dir(
config_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to start Python: {e}"))?;
// Write JSON config to stdin
if let Some(ref mut stdin) = child.stdin {
use std::io::Write;
let json_bytes = dev_config.to_string();
stdin
.write_all(json_bytes.as_bytes())
.map_err(|e| format!("Failed to write config to Python stdin: {e}"))?;
}
// Drop stdin to signal EOF
drop(child.stdin.take());
let status = child
.wait()
.map_err(|e| format!("Failed to wait for Python: {e}"))?;
if !status.success() {
let code = status.code().unwrap_or(1);
if code == 130 {
// User interrupted with Ctrl-C
return Ok(());
}
return Err(format!("Development server exited with code {code}"));
}
Ok(())
}
-232
View File
@@ -1,232 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use console::style;
use crate::analytics;
use crate::config::validate_config_file;
use crate::docker::capabilities::check_capabilities;
use crate::docker::compose::compose_as_dict;
use crate::docker::compose::dict_to_yaml;
use crate::docker::dockerfile::config_to_docker;
use crate::util::warn_non_wolfi_distro;
/// Docker ignore file content.
fn get_docker_ignore_content() -> &'static str {
"\
# Ignore node_modules and other dependency directories
node_modules
bower_components
vendor
# Ignore logs and temporary files
*.log
*.tmp
*.swp
# Ignore .env files and other environment files
.env
.env.*
*.local
# Ignore git-related files
.git
.gitignore
# Ignore Docker-related files and configs
.dockerignore
docker-compose.yml
# Ignore build and cache directories
dist
build
.cache
__pycache__
# Ignore IDE and editor configurations
.vscode
.idea
*.sublime-project
*.sublime-workspace
.DS_Store # macOS-specific
# Ignore test and coverage files
coverage
*.coverage
*.test.js
*.spec.js
tests
"
}
/// Generate a Dockerfile for the LangGraph API server.
pub fn run(
save_path: &str,
config: &str,
add_docker_compose: bool,
base_image: Option<&str>,
api_version: Option<&str>,
) -> Result<(), String> {
// Fire-and-forget analytics
let mut params = HashMap::new();
params.insert(
"add_docker_compose".to_string(),
add_docker_compose.to_string(),
);
analytics::log_command("dockerfile", &params);
let save_path = Path::new(save_path);
let abs_save_path = if save_path.is_absolute() {
save_path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_default()
.join(save_path)
};
eprintln!(
"{}",
style(format!("Validating configuration at path: {config}")).yellow()
);
let config_path = Path::new(config);
let mut config_json = validate_config_file(config_path)?;
let config_value = serde_json::to_value(&config_json).unwrap();
warn_non_wolfi_distro(&config_value);
eprintln!("{}", style("Configuration validated!").green());
eprintln!(
"{}",
style(format!(
"Generating Dockerfile at {}",
abs_save_path.display()
))
.yellow()
);
let (dockerfile_content, additional_contexts) = config_to_docker(
config_path,
&mut config_json,
base_image,
api_version,
None,
None,
None,
false,
)?;
std::fs::write(&abs_save_path, &dockerfile_content)
.map_err(|e| format!("Failed to write Dockerfile: {e}"))?;
eprintln!("{}", style("Created: Dockerfile").green());
if !additional_contexts.is_empty() {
let ctx_str: Vec<String> = additional_contexts
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
eprintln!(
"{}",
style(format!(
"Run docker build with these additional build contexts `--build-context {}`",
ctx_str.join(",")
))
.yellow()
);
}
if add_docker_compose {
let parent = abs_save_path.parent().unwrap_or(Path::new("."));
// Write .dockerignore
let dockerignore_path = parent.join(".dockerignore");
std::fs::write(&dockerignore_path, get_docker_ignore_content())
.map_err(|e| format!("Failed to write .dockerignore: {e}"))?;
eprintln!("{}", style("Created: .dockerignore").green());
// Generate docker-compose.yml
let capabilities = check_capabilities()?;
let mut compose_dict = compose_as_dict(
&capabilities,
8123,
None,
None,
None,
None,
base_image,
api_version,
);
// Add env_file and build context to langgraph-api service
if let Some(crate::docker::compose::YamlValue::Dict(ref mut services)) =
compose_dict.get_mut("services")
{
if let Some(crate::docker::compose::YamlValue::Dict(ref mut api_service)) =
services.get_mut("langgraph-api")
{
api_service.insert(
"env_file".to_string(),
crate::docker::compose::YamlValue::List(vec![".env".to_string()]),
);
let dockerfile_name = abs_save_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mut build_config = indexmap::IndexMap::new();
build_config.insert(
"context".to_string(),
crate::docker::compose::YamlValue::String(".".to_string()),
);
build_config.insert(
"dockerfile".to_string(),
crate::docker::compose::YamlValue::String(dockerfile_name),
);
api_service.insert(
"build".to_string(),
crate::docker::compose::YamlValue::Dict(build_config),
);
}
}
let compose_yaml = dict_to_yaml(&compose_dict, 0);
let compose_path = parent.join("docker-compose.yml");
std::fs::write(&compose_path, &compose_yaml)
.map_err(|e| format!("Failed to write docker-compose.yml: {e}"))?;
eprintln!("{}", style("Created: docker-compose.yml").green());
// Create .env file if it doesn't exist
let env_path = parent.join(".env");
if !env_path.exists() {
let env_content = "\
# Uncomment the following line to add your LangSmith API key
# LANGSMITH_API_KEY=your-api-key
# Or if you have a LangSmith Deployment license key, then uncomment the following line:
# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key
# Add any other environment variables go below...
";
std::fs::write(&env_path, env_content)
.map_err(|e| format!("Failed to write .env: {e}"))?;
eprintln!("{}", style("Created: .env").green());
} else {
eprintln!(
"{}",
style("Skipped: .env. It already exists!").yellow()
);
}
}
eprintln!(
"{}",
style(format!(
"Files generated successfully at path {}!",
abs_save_path
.parent()
.unwrap_or(Path::new("."))
.display()
))
.cyan()
.bold()
);
Ok(())
}
-5
View File
@@ -1,5 +0,0 @@
pub mod build_cmd;
pub mod dev;
pub mod dockerfile;
pub mod new;
pub mod up;
-16
View File
@@ -1,16 +0,0 @@
use std::collections::HashMap;
use crate::analytics;
use crate::templates;
/// Create a new LangGraph project from a template.
pub fn run(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
// Fire-and-forget analytics
let mut params = HashMap::new();
if let Some(t) = template {
params.insert("template".to_string(), t.to_string());
}
analytics::log_command("new", &params);
templates::create_new(path, template)
}
-198
View File
@@ -1,198 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use console::style;
use crate::analytics;
use crate::config::docker_tag::{default_base_image, docker_tag};
use crate::config::validate_config_file;
use crate::docker::capabilities::{check_capabilities, ComposeType};
use crate::docker::compose::compose;
use crate::docker::dockerfile::config_to_compose;
use crate::exec::{run_command, run_command_streaming_with_callback};
use crate::progress::Progress;
use crate::util::warn_non_wolfi_distro;
/// Launch LangGraph API server with Docker Compose.
#[allow(clippy::too_many_arguments)]
pub fn run(
config: &str,
port: u16,
docker_compose: Option<&str>,
verbose: bool,
watch: bool,
recreate: bool,
pull: bool,
wait: bool,
debugger_port: Option<u16>,
debugger_base_url: Option<&str>,
postgres_uri: Option<&str>,
api_version: Option<&str>,
image: Option<&str>,
base_image: Option<&str>,
) -> Result<(), String> {
// Fire-and-forget analytics
let mut params = HashMap::new();
params.insert("verbose".to_string(), verbose.to_string());
params.insert("watch".to_string(), watch.to_string());
params.insert("recreate".to_string(), recreate.to_string());
params.insert("pull".to_string(), pull.to_string());
params.insert("wait".to_string(), wait.to_string());
analytics::log_command("up", &params);
eprintln!("{}", style("Starting LangGraph API server...").green());
eprintln!(
"For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.\n\
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY."
);
let progress = Progress::new("Pulling...");
// Validate config
let config_path = Path::new(config);
let mut config_json = validate_config_file(config_path)?;
let config_value = serde_json::to_value(&config_json).unwrap();
warn_non_wolfi_distro(&config_value);
// Check docker capabilities
let capabilities = check_capabilities()?;
// Pull latest images if requested
if pull {
let tag = docker_tag(&config_json, base_image, api_version);
progress.set_message("Pulling...");
run_command("docker", &["pull", &tag], None, verbose)?;
}
// Generate compose YAML
let debugger_base_url_resolved = debugger_base_url
.map(|s| s.to_string())
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
let mut compose_stdin = compose(
&capabilities,
port,
debugger_port,
Some(&debugger_base_url_resolved),
postgres_uri,
image,
base_image,
api_version,
);
// Append config-to-compose output (build instructions, env, watch sections)
let base_img = base_image
.map(|s| s.to_string())
.unwrap_or_else(|| default_base_image(&config_json));
let compose_config = config_to_compose(
config_path,
&mut config_json,
Some(&base_img),
api_version,
image,
watch,
)?;
compose_stdin.push_str(&compose_config);
// Build docker compose args
let mut args: Vec<String> = Vec::new();
args.push("--project-directory".to_string());
args.push(
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_string_lossy()
.to_string(),
);
if let Some(dc) = docker_compose {
args.push("-f".to_string());
args.push(dc.to_string());
}
// Read compose from stdin
args.push("-f".to_string());
args.push("-".to_string());
// Add up + options
args.push("up".to_string());
args.push("--remove-orphans".to_string());
if recreate {
args.push("--force-recreate".to_string());
args.push("--renew-anon-volumes".to_string());
// Try to remove the volume, ignore errors
let _ = run_command("docker", &["volume", "rm", "langgraph-data"], None, false);
}
if watch {
args.push("--watch".to_string());
}
if wait {
args.push("--wait".to_string());
} else {
args.push("--abort-on-container-exit".to_string());
}
progress.set_message("Building...");
// Determine compose command
let compose_cmd = match capabilities.compose_type {
ComposeType::Plugin => vec!["docker", "compose"],
ComposeType::Standalone => vec!["docker-compose"],
};
// Build final command args
let mut cmd_args: Vec<&str> = Vec::new();
if compose_cmd.len() > 1 {
// "docker compose ..."
cmd_args.extend_from_slice(&compose_cmd[1..]);
}
for a in &args {
cmd_args.push(a.as_str());
}
// Run docker compose with streaming output, intercepting stdout
// to detect startup and show Ready! URLs
let mut ready_printed = false;
let debugger_base_url_query = debugger_base_url
.map(|s| s.to_string())
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
run_command_streaming_with_callback(
compose_cmd[0],
&cmd_args,
Some(&compose_stdin),
verbose,
|line| {
if !ready_printed {
if line.contains("unpacking to docker.io") {
progress.set_message("Starting...");
} else if line.contains("Application startup complete") {
progress.finish();
ready_printed = true;
let debugger_origin = if let Some(dp) = debugger_port {
format!("http://localhost:{dp}")
} else {
"https://smith.langchain.com".to_string()
};
println!(
"Ready!\n\
- API: http://localhost:{port}\n\
- Docs: http://localhost:{port}/docs\n\
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}"
);
}
}
},
)?;
if !ready_printed {
progress.finish();
}
Ok(())
}
-458
View File
@@ -1,458 +0,0 @@
use super::schema::Config;
use crate::constants::DEFAULT_IMAGE_DISTRO;
/// Get the default base image for a config.
pub fn default_base_image(config: &Config) -> String {
if let Some(ref base) = config.base_image {
return base.clone();
}
if config.node_version.is_some() && config.python_version.is_none() {
"langchain/langgraphjs-api".to_string()
} else {
"langchain/langgraph-api".to_string()
}
}
/// Build the Docker image tag string.
pub fn docker_tag(
config: &Config,
base_image: Option<&str>,
api_version: Option<&str>,
) -> String {
let api_version = api_version
.map(|s| s.to_string())
.or_else(|| config.api_version.clone());
let base_image = base_image
.map(|s| s.to_string())
.unwrap_or_else(|| default_base_image(config));
let image_distro = config
.image_distro
.as_deref()
.unwrap_or(DEFAULT_IMAGE_DISTRO);
let distro_tag = if image_distro == DEFAULT_IMAGE_DISTRO {
String::new()
} else {
format!("-{image_distro}")
};
if let Some(ref tag) = config.internal_docker_tag {
return format!("{base_image}:{tag}");
}
// Build the standard tag format
let (language, version) = if config.node_version.is_some() && config.python_version.is_none() {
("node", config.node_version.as_deref().unwrap_or("20"))
} else {
(
"py",
config
.python_version
.as_deref()
.unwrap_or(crate::constants::DEFAULT_PYTHON_VERSION),
)
};
let version_distro_tag = format!("{version}{distro_tag}");
if let Some(api_ver) = api_version {
format!("{base_image}:{api_ver}-{language}{version_distro_tag}")
} else if base_image.contains("/langgraph-server") && !base_image.contains(&version_distro_tag)
{
format!("{base_image}-{language}{version_distro_tag}")
} else {
format!("{base_image}:{version_distro_tag}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::Config;
use crate::config::validate_config;
use serde_json::json;
fn config_from_json(v: serde_json::Value) -> Config {
serde_json::from_value(v).unwrap()
}
#[test]
fn test_default_base_image_python() {
let config = Config {
python_version: Some("3.11".to_string()),
..Default::default()
};
assert_eq!(default_base_image(&config), "langchain/langgraph-api");
}
#[test]
fn test_default_base_image_node() {
let config = Config {
node_version: Some("20".to_string()),
..Default::default()
};
assert_eq!(default_base_image(&config), "langchain/langgraphjs-api");
}
#[test]
fn test_docker_tag_basic() {
let config = Config {
python_version: Some("3.11".to_string()),
..Default::default()
};
assert_eq!(
docker_tag(&config, None, None),
"langchain/langgraph-api:3.11"
);
}
#[test]
fn test_docker_tag_with_api_version_basic() {
let config = Config {
python_version: Some("3.11".to_string()),
..Default::default()
};
assert_eq!(
docker_tag(&config, None, Some("0.2.74")),
"langchain/langgraph-api:0.2.74-py3.11"
);
}
#[test]
fn test_docker_tag_wolfi() {
let config = Config {
python_version: Some("3.12".to_string()),
image_distro: Some("wolfi".to_string()),
..Default::default()
};
assert_eq!(
docker_tag(&config, None, None),
"langchain/langgraph-api:3.12-wolfi"
);
}
#[test]
fn test_docker_tag_internal() {
let config = Config {
python_version: Some("3.11".to_string()),
internal_docker_tag: Some("custom-tag".to_string()),
..Default::default()
};
assert_eq!(
docker_tag(&config, None, None),
"langchain/langgraph-api:custom-tag"
);
}
// ---------------------------------------------------------------
// Comprehensive tests ported from Python test_config.py
// ---------------------------------------------------------------
#[test]
fn test_docker_tag_image_distro() {
// Test 1: Default distro (debian) - no suffix
let cfg = config_from_json(json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
// Test 2: Explicit debian distro - same as default
let cfg = config_from_json(json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "debian"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
// Test 3: Wolfi distro with python
let cfg = config_from_json(json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
"langchain/langgraph-api:3.11-wolfi"
);
// Test 4: Node.js with default distro
let cfg = config_from_json(json!({
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"}
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
"langchain/langgraphjs-api:20"
);
// Test 5: Node.js with wolfi distro
let cfg = config_from_json(json!({
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
"langchain/langgraphjs-api:20-wolfi"
);
// Test 6: Custom base image with wolfi
let cfg = config_from_json(json!({
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi",
"base_image": "my-registry/custom-image"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, Some("my-registry/custom-image"), None),
"my-registry/custom-image:3.12-wolfi"
);
}
#[test]
fn test_docker_tag_multiplatform_with_distro() {
// Test 1: Python + Node with wolfi -> defaults to Python
let cfg = config_from_json(json!({
"python_version": "3.11",
"node_version": "20",
"dependencies": ["."],
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
"image_distro": "wolfi"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
"langchain/langgraph-api:3.11-wolfi"
);
// Test 2: Node-only with wolfi
let cfg = config_from_json(json!({
"node_version": "20",
"graphs": {"js": "./agent.js:graph"},
"image_distro": "wolfi"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
"langchain/langgraphjs-api:20-wolfi"
);
}
#[test]
fn test_docker_tag_different_python_versions_with_distro() {
for (ver, expected) in &[
("3.11", "langchain/langgraph-api:3.11-wolfi"),
("3.12", "langchain/langgraph-api:3.12-wolfi"),
("3.13", "langchain/langgraph-api:3.13-wolfi"),
] {
let cfg = config_from_json(json!({
"python_version": ver,
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
*expected,
"Failed for Python {ver}"
);
}
}
#[test]
fn test_docker_tag_different_node_versions_with_distro() {
for (ver, expected) in &[
("20", "langchain/langgraphjs-api:20-wolfi"),
("21", "langchain/langgraphjs-api:21-wolfi"),
("22", "langchain/langgraphjs-api:22-wolfi"),
] {
let cfg = config_from_json(json!({
"node_version": ver,
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, None),
*expected,
"Failed for Node.js {ver}"
);
}
}
/// Helper to run a single api_version test scenario with both in_config and as-argument modes.
fn run_api_version_test(
base_json: serde_json::Value,
api_version: &str,
base_image_arg: Option<&str>,
expected: &str,
) {
// Mode 1: api_version passed as function argument (not in config)
{
let cfg = config_from_json(base_json.clone());
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, base_image_arg, Some(api_version)),
expected,
"Failed with api_version as argument"
);
}
// Mode 2: api_version set in config (not passed as argument)
{
let mut json_val = base_json.clone();
json_val
.as_object_mut()
.unwrap()
.insert("api_version".to_string(), json!(api_version));
let cfg = config_from_json(json_val);
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, base_image_arg, None),
expected,
"Failed with api_version in config"
);
}
}
#[test]
fn test_docker_tag_with_api_version() {
let version = "0.2.74";
// Test 1: Python config with api_version and default distro
run_api_version_test(
json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}),
version,
None,
"langchain/langgraph-api:0.2.74-py3.11",
);
// Test 2: Python config with api_version and wolfi distro
run_api_version_test(
json!({
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi"
}),
version,
None,
"langchain/langgraph-api:0.2.74-py3.12-wolfi",
);
// Test 3: Node.js config with api_version and default distro
run_api_version_test(
json!({
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"}
}),
version,
None,
"langchain/langgraphjs-api:0.2.74-node20",
);
// Test 4: Node.js config with api_version and wolfi distro
run_api_version_test(
json!({
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
"image_distro": "wolfi"
}),
version,
None,
"langchain/langgraphjs-api:0.2.74-node20-wolfi",
);
// Test 5: Custom base image with api_version
run_api_version_test(
json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"base_image": "my-registry/custom-image"
}),
version,
Some("my-registry/custom-image"),
"my-registry/custom-image:0.2.74-py3.11",
);
// Test 6: Different Python versions with api_version
for py_ver in &["3.11", "3.12", "3.13"] {
let expected = format!("langchain/langgraph-api:{version}-py{py_ver}");
run_api_version_test(
json!({
"python_version": py_ver,
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}),
version,
None,
&expected,
);
}
// Test 7: Without api_version should work as before
let cfg = config_from_json(json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
// Test 8: Multiplatform with api_version (should default to Python)
run_api_version_test(
json!({
"python_version": "3.11",
"node_version": "20",
"dependencies": ["."],
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"}
}),
version,
None,
"langchain/langgraph-api:0.2.74-py3.11",
);
// Test 9: _INTERNAL_docker_tag ignores api_version
// (can only test with api_version as argument since both in config is invalid)
let cfg = config_from_json(json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"_INTERNAL_docker_tag": "internal-tag"
}));
let cfg = validate_config(cfg).unwrap();
assert_eq!(
docker_tag(&cfg, None, Some("0.2.74")),
"langchain/langgraph-api:internal-tag"
);
// Test 10: langgraph-server base image with api_version
run_api_version_test(
json!({
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}),
version,
Some("langchain/langgraph-server"),
"langchain/langgraph-server:0.2.74-py3.11",
);
}
}
-185
View File
@@ -1,185 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use super::schema::Config;
use crate::constants::RESERVED_PACKAGE_NAMES;
/// Container for referencing and managing local Python dependencies.
#[derive(Debug, Clone)]
pub struct LocalDeps {
/// (host_requirements_path, container_requirements_path)
pub pip_reqs: Vec<(PathBuf, String)>,
/// host_path -> (dependency_string, container_package_name)
pub real_pkgs: IndexMap<PathBuf, (String, String)>,
/// host_path -> (dependency_string, container_path)
pub faux_pkgs: IndexMap<PathBuf, (String, String)>,
/// If "." is in dependencies, use it as working_dir
pub working_dir: Option<String>,
/// Directories outside the config parent that need additional Docker build contexts
pub additional_contexts: Vec<PathBuf>,
}
impl Default for LocalDeps {
fn default() -> Self {
Self {
pip_reqs: Vec::new(),
real_pkgs: IndexMap::new(),
faux_pkgs: IndexMap::new(),
working_dir: None,
additional_contexts: Vec::new(),
}
}
}
/// Assemble local dependencies from config.
pub fn assemble_local_deps(
config_path: &Path,
config: &Config,
) -> Result<LocalDeps, String> {
let config_path = config_path
.canonicalize()
.map_err(|e| format!("Could not resolve config path: {e}"))?;
let config_parent = config_path.parent().unwrap();
let mut reserved: HashSet<String> = RESERVED_PACKAGE_NAMES
.iter()
.map(|s| s.to_string())
.collect();
let mut counter: HashMap<String, usize> = HashMap::new();
let check_reserved = |name: &str, ref_str: &str, reserved: &mut HashSet<String>| -> Result<(), String> {
if reserved.contains(name) {
return Err(format!(
"Package name '{name}' used in local dep '{ref_str}' is reserved. Rename the directory."
));
}
reserved.insert(name.to_string());
Ok(())
};
let mut pip_reqs = Vec::new();
let mut real_pkgs = IndexMap::new();
let mut faux_pkgs = IndexMap::new();
let mut working_dir: Option<String> = None;
let mut additional_contexts = Vec::new();
for local_dep in &config.dependencies {
if !local_dep.starts_with('.') {
continue;
}
let resolved = (config_parent.join(local_dep))
.canonicalize()
.map_err(|_| format!("Could not find local dependency: {}", config_parent.join(local_dep).display()))?;
if !resolved.exists() {
return Err(format!("Could not find local dependency: {}", resolved.display()));
}
if !resolved.is_dir() {
return Err(format!(
"Local dependency must be a directory: {}",
resolved.display()
));
}
if resolved != config_parent && !resolved.starts_with(config_parent) {
additional_contexts.push(resolved.clone());
}
let files: Vec<String> = std::fs::read_dir(&resolved)
.map_err(|e| format!("Could not read directory {}: {e}", resolved.display()))?
.filter_map(|entry| entry.ok().map(|e| e.file_name().to_string_lossy().to_string()))
.collect();
if files.contains(&"pyproject.toml".to_string())
|| files.contains(&"setup.py".to_string())
{
// Real package
let dir_name = resolved
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let count = counter.entry(dir_name.clone()).or_insert(0);
let container_name = if *count > 0 {
format!("{}_{}", dir_name, count)
} else {
dir_name.clone()
};
*count += 1;
real_pkgs.insert(resolved.clone(), (local_dep.clone(), container_name.clone()));
if local_dep == "." {
working_dir = Some(format!("/deps/{container_name}"));
}
} else {
// Faux package
let dir_name = resolved
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
if files.contains(&"__init__.py".to_string()) {
// Flat layout
if dir_name.contains('-') {
return Err(format!(
"Package name '{dir_name}' contains a hyphen. \
Rename the directory to use it as flat-layout package."
));
}
check_reserved(&dir_name, local_dep, &mut reserved)?;
let container_path = format!("/deps/outer-{dir_name}/{dir_name}");
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
if local_dep == "." {
working_dir = Some(container_path);
}
} else {
// Src layout
let container_path = format!("/deps/outer-{dir_name}/src");
for file in &files {
let rfile = resolved.join(file);
if rfile.is_dir() && file != "__pycache__" && !file.starts_with('.') {
if let Ok(entries) = std::fs::read_dir(&rfile) {
for subentry in entries.flatten() {
let subname = subentry.file_name().to_string_lossy().to_string();
if subname.ends_with(".py") {
check_reserved(file, local_dep, &mut reserved)?;
break;
}
}
}
}
}
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
if local_dep == "." {
working_dir = Some(container_path);
}
}
// Check for requirements.txt
if files.contains(&"requirements.txt".to_string()) {
let rfile = resolved.join("requirements.txt");
let container_req_path = if let Some((_, ref cp)) = faux_pkgs.get(&resolved) {
format!("{cp}/requirements.txt")
} else {
format!("/deps/outer-{dir_name}/requirements.txt")
};
pip_reqs.push((rfile, container_req_path));
}
}
}
Ok(LocalDeps {
pip_reqs,
real_pkgs,
faux_pkgs,
working_dir,
additional_contexts,
})
}
-849
View File
@@ -1,849 +0,0 @@
pub mod docker_tag;
pub mod local_deps;
pub mod path_rewrite;
pub mod schema;
use std::path::Path;
use schema::{Config, EnvConfig, GraphSpec, KeepPkgTools};
use crate::constants::{
BUILD_TOOLS, DEFAULT_IMAGE_DISTRO, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION,
MIN_NODE_VERSION, MIN_PYTHON_VERSION, VALID_DISTROS, VALID_PIP_INSTALLERS,
};
/// Check if a graph spec references a Node.js file based on extension.
pub fn is_node_graph(spec: &GraphSpec) -> bool {
let path_str = match spec {
GraphSpec::Path(s) => s.as_str(),
GraphSpec::Dict(m) => match m.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return false,
},
};
let file_path = path_str.split(':').next().unwrap_or("");
matches!(
Path::new(file_path)
.extension()
.and_then(|e| e.to_str()),
Some("ts" | "mts" | "cts" | "js" | "mjs" | "cjs")
)
}
/// Parse a Python version string "major.minor" into (major, minor).
fn parse_version(version_str: &str) -> Result<(u32, u32), String> {
let cleaned = version_str.split('-').next().unwrap_or(version_str);
let parts: Vec<&str> = cleaned.split('.').collect();
if parts.len() != 2 {
return Err(format!("Invalid version format: {version_str}"));
}
let major: u32 = parts[0]
.parse()
.map_err(|_| format!("Invalid version format: {version_str}"))?;
let minor: u32 = parts[1]
.parse()
.map_err(|_| format!("Invalid version format: {version_str}"))?;
Ok((major, minor))
}
/// Parse a Node.js version string (major only) into u32.
fn parse_node_version(version_str: &str) -> Result<u32, String> {
if version_str.contains('.') {
return Err(format!(
"Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."
));
}
version_str
.parse::<u32>()
.map_err(|_| format!("Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."))
}
/// Validate a configuration dictionary.
pub fn validate_config(mut config: Config) -> Result<Config, String> {
let graphs = &config.graphs;
let some_node = graphs.values().any(|spec| is_node_graph(spec));
let some_python = graphs.values().any(|spec| !is_node_graph(spec));
// Set defaults for node_version and python_version
if config.node_version.is_none() && some_node {
config.node_version = Some(DEFAULT_NODE_VERSION.to_string());
}
if config.python_version.is_none() && some_python {
config.python_version = Some(DEFAULT_PYTHON_VERSION.to_string());
}
// Default image_distro
if config.image_distro.is_none() {
config.image_distro = Some(DEFAULT_IMAGE_DISTRO.to_string());
}
// Default pip_installer
if config.pip_installer.is_none() {
config.pip_installer = Some("auto".to_string());
}
// Default env
if config.env.is_none() {
config.env = Some(EnvConfig::default());
}
// Validate _INTERNAL_docker_tag vs api_version
if config.internal_docker_tag.is_some() && config.api_version.is_some() {
return Err("Cannot specify both _INTERNAL_docker_tag and api_version.".to_string());
}
// Validate api_version format
if let Some(ref api_version) = config.api_version {
let cleaned = api_version.split('-').next().unwrap_or(api_version);
let parts: Vec<&str> = cleaned.split('.').collect();
if parts.len() > 3 {
return Err("Version must be major or major.minor or major.minor.patch.".to_string());
}
for part in &parts {
part.parse::<u32>()
.map_err(|_| format!("Invalid version format: {api_version}"))?;
}
}
// Validate node_version
if let Some(ref node_version) = config.node_version {
let major = parse_node_version(node_version)?;
if major < MIN_NODE_VERSION {
return Err(format!(
"Node.js version {node_version} is not supported. Minimum required version is {MIN_NODE_VERSION}."
));
}
}
// Validate python_version
if let Some(ref pyversion) = config.python_version {
let cleaned = pyversion.split('-').next().unwrap_or(pyversion);
if cleaned.split('.').count() != 2
|| !cleaned.split('.').all(|p| p.chars().all(|c| c.is_ascii_digit()))
{
return Err(format!(
"Invalid Python version format: {pyversion}. \
Use 'major.minor' format (e.g., '3.11'). \
Patch version cannot be specified."
));
}
if parse_version(pyversion)? < MIN_PYTHON_VERSION {
return Err(format!(
"Python version {pyversion} is not supported. \
Minimum required version is {}.{}.",
MIN_PYTHON_VERSION.0, MIN_PYTHON_VERSION.1
));
}
if config.dependencies.is_empty() {
return Err(
"No dependencies found in config. Add at least one dependency to 'dependencies' list."
.to_string(),
);
}
}
// Validate graphs
if config.graphs.is_empty() {
return Err(
"No graphs found in config. Add at least one graph to 'graphs' dictionary."
.to_string(),
);
}
// Validate image_distro
if let Some(ref distro) = config.image_distro {
if distro == "bullseye" {
return Err(
"Bullseye images were deprecated in version 0.4.13. \
Please use 'bookworm' or 'debian' instead."
.to_string(),
);
}
if !VALID_DISTROS.contains(&distro.as_str()) {
return Err(format!(
"Invalid image_distro: '{distro}'. Must be one of 'debian', 'wolfi', or 'bookworm'."
));
}
}
// Validate pip_installer
if let Some(ref pip_installer) = config.pip_installer {
if !VALID_PIP_INSTALLERS.contains(&pip_installer.as_str()) {
return Err(format!(
"Invalid pip_installer: '{pip_installer}'. Must be 'auto', 'pip', or 'uv'."
));
}
}
// Validate auth config
if let Some(ref auth_conf) = config.auth {
if let Some(ref path) = auth_conf.path {
if !path.contains(':') {
return Err(format!(
"Invalid auth.path format: '{path}'. \
Must be in format './path/to/file.py:attribute_name'"
));
}
}
}
// Validate encryption config
if let Some(ref encryption_conf) = config.encryption {
if let Some(ref path) = encryption_conf.path {
if !path.contains(':') {
return Err(format!(
"Invalid encryption.path format: '{path}'. \
Must be in format './path/to/file.py:attribute_name'"
));
}
}
}
// Validate http config
if let Some(ref http_conf) = config.http {
if let Some(ref app) = http_conf.app {
if !app.contains(':') {
return Err(format!(
"Invalid http.app format: '{app}'. \
Must be in format './path/to/file.py:attribute_name'"
));
}
}
}
// Validate keep_pkg_tools
if let Some(ref keep_pkg_tools) = config.keep_pkg_tools {
match keep_pkg_tools {
KeepPkgTools::List(tools) => {
for tool in tools {
if !BUILD_TOOLS.contains(&tool.as_str()) {
return Err(format!(
"Invalid keep_pkg_tools: '{tool}'. \
Must be one of 'pip', 'setuptools', 'wheel'."
));
}
}
}
KeepPkgTools::Bool(_) => {}
}
}
Ok(config)
}
/// Load and validate a configuration file.
pub fn validate_config_file(config_path: &Path) -> Result<Config, String> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| format!("Could not read config file {}: {e}", config_path.display()))?;
let config: Config = serde_json::from_str(&content)
.map_err(|e| format!("Invalid JSON in config file {}: {e}", config_path.display()))?;
let validated = validate_config(config)?;
// Check package.json engines
if validated.node_version.is_some() {
let package_json_path = config_path.parent().unwrap().join("package.json");
if package_json_path.is_file() {
let pkg_content = std::fs::read_to_string(&package_json_path)
.map_err(|e| format!("Could not read package.json: {e}"))?;
let pkg: serde_json::Value = serde_json::from_str(&pkg_content).map_err(|_| {
format!(
"Invalid package.json found in langgraph config directory {}: file is not valid JSON",
package_json_path.display()
)
})?;
if let Some(engines) = pkg.get("engines").and_then(|e| e.as_object()) {
// Only 'node' engine is supported
if engines.keys().any(|k| k != "node") {
return Err(format!(
"Only 'node' engine is supported in package.json engines. Got engines: {:?}",
engines.keys().collect::<Vec<_>>()
));
}
if let Some(node_version) = engines.get("node").and_then(|v| v.as_str()) {
let major = parse_node_version(node_version)?;
if major < MIN_NODE_VERSION {
return Err(format!(
"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} \
(major version only), got '{node_version}'. Minor/patch versions \
(like '20.x.y') are not supported to prevent deployment issues \
when new Node.js versions are released."
));
}
}
}
}
}
Ok(validated)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn config_from_json(val: serde_json::Value) -> Config {
serde_json::from_value::<Config>(val).unwrap()
}
// ---------------------------------------------------------------
// test_validate_config
// ---------------------------------------------------------------
#[test]
fn test_validate_config_minimal() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.11"));
assert_eq!(result.node_version, None);
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
assert_eq!(result.image_distro.as_deref(), Some("debian"));
assert_eq!(result.base_image, None);
}
#[test]
fn test_validate_config_full() {
let config = config_from_json(json!({
"python_version": "3.12",
"dependencies": [".", "langchain"],
"graphs": {"agent": "./agent.py:graph"},
"pip_config_file": "/etc/pip.conf",
"dockerfile_lines": ["RUN apt-get update"],
"env": ".env"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.12"));
assert_eq!(result.pip_config_file.as_deref(), Some("/etc/pip.conf"));
assert_eq!(result.dockerfile_lines, vec!["RUN apt-get update"]);
assert!(matches!(result.env, Some(EnvConfig::File(ref s)) if s == ".env"));
}
#[test]
fn test_validate_config_python_313() {
let config = config_from_json(json!({
"python_version": "3.13",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.13"));
}
#[test]
fn test_validate_config_python_39_error() {
let config = config_from_json(json!({
"python_version": "3.9",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
}
#[test]
fn test_validate_config_missing_dependencies() {
let config = config_from_json(json!({
"python_version": "3.11",
"graphs": {"agent": "./agent.py:graph"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("dependencies"), "Expected error about dependencies but got: {err}");
}
#[test]
fn test_validate_config_missing_graphs() {
let config = config_from_json(json!({
"dependencies": ["."]
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("graphs"), "Expected error about graphs but got: {err}");
}
#[test]
fn test_validate_config_python_version_with_patch() {
let config = config_from_json(json!({
"python_version": "3.11.0",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
}
#[test]
fn test_validate_config_python_version_major_only() {
let config = config_from_json(json!({
"python_version": "3",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
}
#[test]
fn test_validate_config_python_version_non_numeric() {
let config = config_from_json(json!({
"python_version": "abc.def",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
}
#[test]
fn test_validate_config_python_310_error() {
let config = config_from_json(json!({
"python_version": "3.10",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
}
#[test]
fn test_validate_config_python_312_slim() {
let config = config_from_json(json!({
"python_version": "3.12-slim",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.12-slim"));
}
#[test]
fn test_validate_config_http_app_no_colon() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {"app": "../../examples/my_app.py"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid http.app format"), "Expected 'Invalid http.app format' but got: {err}");
}
// ---------------------------------------------------------------
// test_validate_config_image_distro
// ---------------------------------------------------------------
#[test]
fn test_validate_config_image_distro_debian() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "debian"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.image_distro.as_deref(), Some("debian"));
}
#[test]
fn test_validate_config_image_distro_wolfi() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "wolfi"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
}
#[test]
fn test_validate_config_image_distro_default() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.image_distro.as_deref(), Some("debian"));
}
#[test]
fn test_validate_config_image_distro_bullseye_error() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "bullseye"
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Bullseye images were deprecated"), "Expected 'Bullseye images were deprecated' but got: {err}");
}
#[test]
fn test_validate_config_image_distro_ubuntu_error() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "ubuntu"
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid image_distro: 'ubuntu'"), "Expected \"Invalid image_distro: 'ubuntu'\" but got: {err}");
}
#[test]
fn test_validate_config_image_distro_alpine_error() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"image_distro": "alpine"
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid image_distro: 'alpine'"), "Expected \"Invalid image_distro: 'alpine'\" but got: {err}");
}
#[test]
fn test_validate_config_node_with_wolfi() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"},
"image_distro": "wolfi"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
assert_eq!(result.node_version.as_deref(), Some("20"));
}
#[test]
fn test_validate_config_node_default_distro() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.image_distro.as_deref(), Some("debian"));
}
// ---------------------------------------------------------------
// test_validate_config_pip_installer
// ---------------------------------------------------------------
#[test]
fn test_validate_config_pip_installer_auto() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"pip_installer": "auto"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
}
#[test]
fn test_validate_config_pip_installer_pip() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"pip_installer": "pip"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.pip_installer.as_deref(), Some("pip"));
}
#[test]
fn test_validate_config_pip_installer_uv() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"pip_installer": "uv"
}));
let result = validate_config(config).unwrap();
assert_eq!(result.pip_installer.as_deref(), Some("uv"));
}
#[test]
fn test_validate_config_pip_installer_default() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
}
#[test]
fn test_validate_config_pip_installer_conda_error() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"pip_installer": "conda"
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid pip_installer: 'conda'"), "Expected \"Invalid pip_installer: 'conda'\" but got: {err}");
assert!(err.contains("Must be 'auto', 'pip', or 'uv'"), "Expected \"Must be 'auto', 'pip', or 'uv'\" but got: {err}");
}
#[test]
fn test_validate_config_pip_installer_invalid_error() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"pip_installer": "invalid"
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid pip_installer: 'invalid'"), "Expected \"Invalid pip_installer: 'invalid'\" but got: {err}");
}
// ---------------------------------------------------------------
// test_validate_config_multiplatform
// ---------------------------------------------------------------
#[test]
fn test_validate_config_js_only_no_explicit_versions() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./js.mts:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.node_version.as_deref(), Some("20"));
assert_eq!(result.python_version, None);
}
#[test]
fn test_validate_config_both_versions_explicit() {
let config = config_from_json(json!({
"python_version": "3.12",
"node_version": "22",
"dependencies": ["."],
"graphs": {
"agent_py": "./agent.py:graph",
"agent_js": "./agent.mts:graph"
}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.12"));
assert_eq!(result.node_version.as_deref(), Some("22"));
}
#[test]
fn test_validate_config_mixed_graphs_no_explicit_versions() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {
"agent_py": "./agent.py:graph",
"agent_js": "./agent.mts:graph"
}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.node_version.as_deref(), Some("20"));
assert_eq!(result.python_version.as_deref(), Some("3.11"));
}
#[test]
fn test_validate_config_mixed_graphs_node_version_only() {
let config = config_from_json(json!({
"node_version": "22",
"dependencies": ["."],
"graphs": {
"agent_py": "./agent.py:graph",
"agent_js": "./agent.mts:graph"
}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.node_version.as_deref(), Some("22"));
assert_eq!(result.python_version.as_deref(), Some("3.11"));
}
#[test]
fn test_validate_config_mixed_graphs_python_version_only() {
let config = config_from_json(json!({
"python_version": "3.12",
"dependencies": ["."],
"graphs": {
"agent_py": "./agent.py:graph",
"agent_js": "./agent.mts:graph"
}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.node_version.as_deref(), Some("20"));
assert_eq!(result.python_version.as_deref(), Some("3.12"));
}
#[test]
fn test_validate_config_unknown_extension_assumes_python() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "local.workflow:graph"}
}));
let result = validate_config(config).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.11"));
assert_eq!(result.node_version, None);
}
// ---------------------------------------------------------------
// test_validate_config_encryption
// ---------------------------------------------------------------
#[test]
fn test_validate_config_encryption_valid() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"encryption": {"path": "./encryption.py:encryption"}
}));
let result = validate_config(config).unwrap();
assert!(result.encryption.is_some());
assert_eq!(
result.encryption.as_ref().unwrap().path.as_deref(),
Some("./encryption.py:encryption")
);
}
#[test]
fn test_validate_config_encryption_no_colon_error() {
let config = config_from_json(json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"encryption": {"path": "./encryption.py"}
}));
let err = validate_config(config).unwrap_err();
assert!(err.contains("Invalid encryption.path format"), "Expected 'Invalid encryption.path format' but got: {err}");
}
// ---------------------------------------------------------------
// test_validate_config_file
// ---------------------------------------------------------------
#[test]
fn test_validate_config_file_node_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
});
let mut f = std::fs::File::create(&config_path).unwrap();
write!(f, "{}", serde_json::to_string(&config_json).unwrap()).unwrap();
drop(f);
let result = validate_config_file(&config_path).unwrap();
assert_eq!(result.node_version.as_deref(), Some("20"));
}
#[test]
fn test_validate_config_file_with_valid_package_json() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
});
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
let pkg_path = tmp.path().join("package.json");
let pkg_json = json!({
"engines": {"node": "20"}
});
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
let result = validate_config_file(&config_path).unwrap();
assert_eq!(result.node_version.as_deref(), Some("20"));
}
#[test]
fn test_validate_config_file_package_json_minor_version_error() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
});
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
let pkg_path = tmp.path().join("package.json");
let pkg_json = json!({
"engines": {"node": "20.18"}
});
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
let err = validate_config_file(&config_path).unwrap_err();
assert!(err.contains("Use major version only"), "Expected 'Use major version only' but got: {err}");
}
#[test]
fn test_validate_config_file_package_json_old_node_error() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
});
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
let pkg_path = tmp.path().join("package.json");
let pkg_json = json!({
"engines": {"node": "18"}
});
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
let err = validate_config_file(&config_path).unwrap_err();
assert!(err.contains("must be >= 20"), "Expected 'must be >= 20' but got: {err}");
}
#[test]
fn test_validate_config_file_package_json_deno_engine_error() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
});
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
let pkg_path = tmp.path().join("package.json");
let pkg_json = json!({
"engines": {"node": "20", "deno": "1.0"}
});
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
let err = validate_config_file(&config_path).unwrap_err();
assert!(err.contains("Only 'node' engine is supported"), "Expected 'Only node engine is supported' but got: {err}");
}
#[test]
fn test_validate_config_file_invalid_package_json() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.mts:graph"}
});
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
let pkg_path = tmp.path().join("package.json");
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
let err = validate_config_file(&config_path).unwrap_err();
assert!(err.contains("Invalid package.json"), "Expected 'Invalid package.json' but got: {err}");
}
#[test]
fn test_validate_config_file_python_ignores_bad_package_json() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("langgraph.json");
let config_json = json!({
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"}
});
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
// Write a bad package.json - should be ignored for Python-only config
let pkg_path = tmp.path().join("package.json");
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
let result = validate_config_file(&config_path).unwrap();
assert_eq!(result.python_version.as_deref(), Some("3.11"));
}
}
-324
View File
@@ -1,324 +0,0 @@
use std::path::Path;
use super::local_deps::LocalDeps;
use super::schema::{Config, GraphSpec};
/// Remap each graph's import path to the correct in-container path.
pub fn update_graph_paths(
config_path: &Path,
config: &mut Config,
local_deps: &LocalDeps,
) -> Result<(), String> {
let config_parent = config_path.parent().unwrap();
let graph_ids: Vec<String> = config.graphs.keys().cloned().collect();
for graph_id in graph_ids {
let import_str = {
let spec = config.graphs.get(&graph_id).unwrap();
match spec {
GraphSpec::Path(s) => s.clone(),
GraphSpec::Dict(m) => {
if let Some(path_val) = m.get("path") {
path_val
.as_str()
.ok_or_else(|| {
format!("Graph '{graph_id}' path must be a string")
})?
.to_string()
} else {
return Err(format!(
"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
));
}
}
}
};
let (module_str, attr_str) = match import_str.split_once(':') {
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
_ => {
return Err(format!(
"Import string \"{import_str}\" must be in format \"<module>:<attribute>\"."
));
}
};
// Check for file path (contains / or \)
if module_str.contains('/') || module_str.contains('\\') {
let resolved = config_parent
.join(module_str)
.canonicalize()
.map_err(|_| format!("Could not find local module: {}", config_parent.join(module_str).display()))?;
if !resolved.exists() {
return Err(format!("Could not find local module: {}", resolved.display()));
}
if !resolved.is_file() {
return Err(format!("Local module must be a file: {}", resolved.display()));
}
let mut new_module = None;
// Check real packages
for (path, (_, container_name)) in &local_deps.real_pkgs {
if resolved.starts_with(path) {
if let Ok(relative) = resolved.strip_prefix(path) {
let container_path =
format!("/deps/{}/{}", container_name, relative.to_string_lossy().replace('\\', "/"));
new_module = Some(container_path);
break;
}
}
}
// Check faux packages
if new_module.is_none() {
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
if resolved.starts_with(faux_path) {
if let Ok(relative) = resolved.strip_prefix(faux_path) {
new_module = Some(format!(
"{}/{}",
destpath,
relative.to_string_lossy().replace('\\', "/")
));
break;
}
}
}
}
if let Some(new_mod) = new_module {
let new_path = format!("{new_mod}:{attr_str}");
config.graphs.get_mut(&graph_id).unwrap().set_path(new_path);
} else {
return Err(format!(
"Module '{import_str}' not found in 'dependencies' list. \
Add its containing package to 'dependencies' list."
));
}
}
}
Ok(())
}
/// Update auth.path to use Docker container paths.
pub fn update_auth_path(
config_path: &Path,
config: &mut Config,
local_deps: &LocalDeps,
) -> Result<(), String> {
let auth_conf = match config.auth.as_mut() {
Some(a) => a,
None => return Ok(()),
};
let path_str = match auth_conf.path.as_ref() {
Some(p) => p.clone(),
None => return Ok(()),
};
let (module_str, attr_str) = match path_str.split_once(':') {
Some((m, a)) => (m, a),
None => return Ok(()),
};
if !module_str.starts_with('.') {
return Ok(());
}
let config_parent = config_path.parent().unwrap();
let resolved = config_parent
.join(module_str)
.canonicalize()
.map_err(|_| format!("Auth file not found: {} (from {path_str})", config_parent.join(module_str).display()))?;
if !resolved.is_file() {
return Err(format!("Auth path must be a file: {}", resolved.display()));
}
// Check faux packages first
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
if resolved.starts_with(faux_path) {
if let Ok(relative) = resolved.strip_prefix(faux_path) {
auth_conf.path = Some(format!(
"{}/{}:{attr_str}",
destpath,
relative.to_string_lossy()
));
return Ok(());
}
}
}
// Check real packages
for (real_path, _) in &local_deps.real_pkgs {
if resolved.starts_with(real_path) {
if let Ok(relative) = resolved.strip_prefix(real_path) {
let dir_name = real_path.file_name().unwrap().to_string_lossy();
auth_conf.path = Some(format!(
"/deps/{}/{}:{attr_str}",
dir_name,
relative.to_string_lossy()
));
return Ok(());
}
}
}
Err(format!(
"Auth file '{}' not covered by dependencies.\n\
Add its parent directory to the 'dependencies' array in your config.",
resolved.display()
))
}
/// Update encryption.path to use Docker container paths.
pub fn update_encryption_path(
config_path: &Path,
config: &mut Config,
local_deps: &LocalDeps,
) -> Result<(), String> {
let encryption_conf = match config.encryption.as_mut() {
Some(e) => e,
None => return Ok(()),
};
let path_str = match encryption_conf.path.as_ref() {
Some(p) => p.clone(),
None => return Ok(()),
};
let (module_str, attr_str) = match path_str.split_once(':') {
Some((m, a)) => (m, a),
None => return Ok(()),
};
if !module_str.starts_with('.') {
return Ok(());
}
let config_parent = config_path.parent().unwrap();
let resolved = config_parent
.join(module_str)
.canonicalize()
.map_err(|_| {
format!(
"Encryption file not found: {} (from {path_str})",
config_parent.join(module_str).display()
)
})?;
if !resolved.is_file() {
return Err(format!(
"Encryption path must be a file: {}",
resolved.display()
));
}
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
if resolved.starts_with(faux_path) {
if let Ok(relative) = resolved.strip_prefix(faux_path) {
encryption_conf.path = Some(format!(
"{}/{}:{attr_str}",
destpath,
relative.to_string_lossy()
));
return Ok(());
}
}
}
for (real_path, _) in &local_deps.real_pkgs {
if resolved.starts_with(real_path) {
if let Ok(relative) = resolved.strip_prefix(real_path) {
let dir_name = real_path.file_name().unwrap().to_string_lossy();
encryption_conf.path = Some(format!(
"/deps/{}/{}:{attr_str}",
dir_name,
relative.to_string_lossy()
));
return Ok(());
}
}
}
Err(format!(
"Encryption file '{}' not covered by dependencies.\n\
Add its parent directory to the 'dependencies' array in your config.",
resolved.display()
))
}
/// Update the HTTP app path to point to the correct location in the Docker container.
pub fn update_http_app_path(
config_path: &Path,
config: &mut Config,
local_deps: &LocalDeps,
) -> Result<(), String> {
let http_config = match config.http.as_mut() {
Some(h) => h,
None => return Ok(()),
};
let app_str = match http_config.app.as_ref() {
Some(a) => a.clone(),
None => return Ok(()),
};
let (module_str, attr_str) = match app_str.split_once(':') {
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
_ => {
return Err(format!(
"Import string \"{app_str}\" must be in format \"<module>:<attribute>\"."
));
}
};
if !module_str.contains('/') && !module_str.contains('\\') {
return Ok(());
}
let config_parent = config_path.parent().unwrap();
let resolved = config_parent
.join(module_str)
.canonicalize()
.map_err(|_| format!("Could not find HTTP app module: {}", config_parent.join(module_str).display()))?;
if !resolved.is_file() {
return Err(format!(
"HTTP app module must be a file: {}",
resolved.display()
));
}
// Check real packages
for (path, (_, _name)) in &local_deps.real_pkgs {
if resolved.starts_with(path) {
if let Ok(relative) = resolved.strip_prefix(path) {
let dir_name = path.file_name().unwrap().to_string_lossy();
http_config.app = Some(format!(
"/deps/{}/{}:{attr_str}",
dir_name,
relative.to_string_lossy().replace('\\', "/")
));
return Ok(());
}
}
}
// Check faux packages
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
if resolved.starts_with(faux_path) {
if let Ok(relative) = resolved.strip_prefix(faux_path) {
http_config.app = Some(format!(
"{}/{}:{attr_str}",
destpath,
relative.to_string_lossy().replace('\\', "/")
));
return Ok(());
}
}
}
Err(format!(
"HTTP app module '{app_str}' not found in 'dependencies' list. \
Add its containing package to 'dependencies' list."
))
}
-186
View File
@@ -1,186 +0,0 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Top-level config for langgraph-cli deployment.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub python_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
#[serde(rename = "_INTERNAL_docker_tag", skip_serializing_if = "Option::is_none")]
pub internal_docker_tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_distro: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_config_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_installer: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dockerfile_lines: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub graphs: IndexMap<String, GraphSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<EnvConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encryption: Option<EncryptionConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<HttpConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhooks: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpointer: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<IndexMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_config: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_pkg_tools: Option<KeepPkgTools>,
}
/// Graph specification: either a string path or a dict with a "path" key.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum GraphSpec {
Path(String),
Dict(IndexMap<String, Value>),
}
impl GraphSpec {
pub fn set_path(&mut self, new_path: String) {
match self {
GraphSpec::Path(s) => *s = new_path,
GraphSpec::Dict(m) => {
m.insert("path".to_string(), Value::String(new_path));
}
}
}
}
/// Environment config: either a dict of key-value pairs or a path to an env file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EnvConfig {
Dict(IndexMap<String, String>),
File(String),
}
impl Default for EnvConfig {
fn default() -> Self {
EnvConfig::Dict(IndexMap::new())
}
}
/// Auth configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_studio_auth: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub openapi: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache: Option<Value>,
}
/// Encryption configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptionConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// HTTP configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_assistants: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_threads: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_runs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_mcp: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_a2a: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_meta: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_ui: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_webhooks: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cors: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub configurable_headers: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logging_headers: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub middleware_order: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_custom_route_auth: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mount_prefix: Option<String>,
}
/// Keep package tools config: either a boolean or a list of tool names.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum KeepPkgTools {
Bool(bool),
List(Vec<String>),
}
-35
View File
@@ -1,35 +0,0 @@
pub const MIN_NODE_VERSION: u32 = 20;
pub const DEFAULT_NODE_VERSION: u32 = 20;
pub const MIN_PYTHON_VERSION: (u32, u32) = (3, 11);
pub const DEFAULT_PYTHON_VERSION: &str = "3.11";
pub const DEFAULT_IMAGE_DISTRO: &str = "debian";
pub const BUILD_TOOLS: &[&str] = &["pip", "setuptools", "wheel"];
// Analytics
pub const SUPABASE_PUBLIC_API_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c";
pub const SUPABASE_URL: &str = "https://kzrlppojinpcyyaipxnb.supabase.co";
pub const DEFAULT_POSTGRES_URI: &str =
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable";
pub const VALID_DISTROS: &[&str] = &["debian", "wolfi", "bookworm"];
pub const VALID_PIP_INSTALLERS: &[&str] = &["auto", "pip", "uv"];
pub const RESERVED_PACKAGE_NAMES: &[&str] = &[
"src",
"langgraph-api",
"langgraph_api",
"langgraph",
"langchain-core",
"langchain_core",
"pydantic",
"orjson",
"fastapi",
"uvicorn",
"psycopg",
"httpx",
"langsmith",
];
-185
View File
@@ -1,185 +0,0 @@
use std::process::Command;
/// Semantic version tuple.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
}
/// Type of Docker Compose installation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComposeType {
Plugin,
Standalone,
}
/// Docker capabilities detected on the system.
#[derive(Debug, Clone)]
pub struct DockerCapabilities {
#[allow(dead_code)]
pub version_docker: Version,
#[allow(dead_code)]
pub version_compose: Version,
pub healthcheck_start_interval: bool,
pub compose_type: ComposeType,
}
/// Parse a version string like "1.2.3", "v1.2.3-alpha", etc.
pub fn parse_version(version: &str) -> Version {
let cleaned = version.trim();
let parts: Vec<&str> = cleaned.split('.').collect();
let parse_part = |s: &str| -> u32 {
let s = s.trim_start_matches('v');
let s = s.split('-').next().unwrap_or(s);
let s = s.split('+').next().unwrap_or(s);
s.parse().unwrap_or(0)
};
match parts.len() {
1 => Version::new(parse_part(parts[0]), 0, 0),
2 => Version::new(parse_part(parts[0]), parse_part(parts[1]), 0),
_ => Version::new(
parse_part(parts[0]),
parse_part(parts[1]),
parse_part(parts[2]),
),
}
}
/// Check Docker capabilities on the system.
pub fn check_capabilities() -> Result<DockerCapabilities, String> {
// Check docker is available
if which::which("docker").is_err() {
return Err("Docker not installed".to_string());
}
// Get docker info
let output = Command::new("docker")
.args(["info", "-f", "{{json .}}"])
.output()
.map_err(|_| "Docker not installed or not running".to_string())?;
if !output.status.success() {
return Err("Docker not installed or not running".to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let info: serde_json::Value =
serde_json::from_str(&stdout).map_err(|_| "Docker not installed or not running".to_string())?;
let server_version = info
.get("ServerVersion")
.and_then(|v| v.as_str())
.ok_or("Docker not running")?;
if server_version.is_empty() {
return Err("Docker not running".to_string());
}
// Try to find compose as plugin
let (compose_version_str, compose_type) = if let Some(plugins) = info
.get("ClientInfo")
.and_then(|ci| ci.get("Plugins"))
.and_then(|p| p.as_array())
{
if let Some(compose) = plugins
.iter()
.find(|p| p.get("Name").and_then(|n| n.as_str()) == Some("compose"))
{
let version = compose
.get("Version")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0");
(version.to_string(), ComposeType::Plugin)
} else {
get_standalone_compose_version()?
}
} else {
get_standalone_compose_version()?
};
let docker_version = parse_version(server_version);
let compose_version = parse_version(&compose_version_str);
Ok(DockerCapabilities {
version_docker: docker_version,
version_compose: compose_version,
healthcheck_start_interval: docker_version >= Version::new(25, 0, 0),
compose_type,
})
}
fn get_standalone_compose_version() -> Result<(String, ComposeType), String> {
if which::which("docker-compose").is_err() {
return Err("Docker Compose not installed".to_string());
}
let output = Command::new("docker-compose")
.args(["--version", "--short"])
.output()
.map_err(|_| "Docker Compose not installed".to_string())?;
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok((version, ComposeType::Standalone))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version_basic() {
assert_eq!(parse_version("1.2.3"), Version::new(1, 2, 3));
}
#[test]
fn test_parse_version_with_v() {
assert_eq!(parse_version("v1.2.3"), Version::new(1, 2, 3));
}
#[test]
fn test_parse_version_with_prerelease() {
assert_eq!(parse_version("1.2.3-alpha"), Version::new(1, 2, 3));
}
#[test]
fn test_parse_version_with_build() {
assert_eq!(parse_version("1.2.3+1"), Version::new(1, 2, 3));
}
#[test]
fn test_parse_version_two_parts() {
assert_eq!(parse_version("1.2"), Version::new(1, 2, 0));
}
#[test]
fn test_parse_version_one_part() {
assert_eq!(parse_version("1"), Version::new(1, 0, 0));
}
#[test]
fn test_parse_version_complex() {
assert_eq!(parse_version("v28.1.1+1"), Version::new(28, 1, 1));
}
#[test]
fn test_parse_version_beta() {
assert_eq!(
parse_version("2.0.0-beta.1+exp.sha.5114f85"),
Version::new(2, 0, 0)
);
}
}
-791
View File
@@ -1,791 +0,0 @@
use indexmap::IndexMap;
use super::capabilities::DockerCapabilities;
use crate::constants::DEFAULT_POSTGRES_URI;
/// Value types for our custom YAML writer.
#[derive(Debug, Clone)]
pub enum YamlValue {
String(String),
Dict(IndexMap<String, YamlValue>),
List(Vec<String>),
}
/// Convert a dictionary to a YAML string with custom formatting.
/// This matches the Python dict_to_yaml() output exactly.
pub fn dict_to_yaml(d: &IndexMap<String, YamlValue>, indent: usize) -> String {
let mut yaml_str = String::new();
for (idx, (key, value)) in d.iter().enumerate() {
// Extra newline for top-level keys only (after the first)
if idx >= 1 && indent < 2 {
yaml_str.push('\n');
}
let space = " ".repeat(indent);
match value {
YamlValue::Dict(inner) => {
yaml_str.push_str(&format!("{space}{key}:\n"));
yaml_str.push_str(&dict_to_yaml(inner, indent + 1));
}
YamlValue::List(items) => {
yaml_str.push_str(&format!("{space}{key}:\n"));
for item in items {
yaml_str.push_str(&format!("{space} - {item}\n"));
}
}
YamlValue::String(val) => {
yaml_str.push_str(&format!("{space}{key}: {val}\n"));
}
}
}
yaml_str
}
/// Create debugger service config.
pub fn debugger_compose(port: Option<u16>, base_url: Option<&str>) -> IndexMap<String, YamlValue> {
let port = match port {
Some(p) => p,
None => return IndexMap::new(),
};
let mut debugger = IndexMap::new();
debugger.insert(
"image".to_string(),
YamlValue::String("langchain/langgraph-debugger".to_string()),
);
debugger.insert(
"restart".to_string(),
YamlValue::String("on-failure".to_string()),
);
let mut depends = IndexMap::new();
let mut pg_condition = IndexMap::new();
pg_condition.insert(
"condition".to_string(),
YamlValue::String("service_healthy".to_string()),
);
depends.insert("langgraph-postgres".to_string(), YamlValue::Dict(pg_condition));
debugger.insert("depends_on".to_string(), YamlValue::Dict(depends));
debugger.insert(
"ports".to_string(),
YamlValue::List(vec![format!("\"{port}:3968\"")]),
);
if let Some(url) = base_url {
let mut env = IndexMap::new();
env.insert(
"VITE_STUDIO_LOCAL_GRAPH_URL".to_string(),
YamlValue::String(url.to_string()),
);
debugger.insert("environment".to_string(), YamlValue::Dict(env));
}
let mut result = IndexMap::new();
result.insert("langgraph-debugger".to_string(), YamlValue::Dict(debugger));
result
}
/// Create a docker compose file as a dictionary.
pub fn compose_as_dict(
capabilities: &DockerCapabilities,
port: u16,
debugger_port: Option<u16>,
debugger_base_url: Option<&str>,
postgres_uri: Option<&str>,
image: Option<&str>,
_base_image: Option<&str>,
_api_version: Option<&str>,
) -> IndexMap<String, YamlValue> {
let include_db = postgres_uri.is_none();
let postgres_uri = postgres_uri.unwrap_or(DEFAULT_POSTGRES_URI);
let mut services = IndexMap::new();
// Redis service
let mut redis = IndexMap::new();
redis.insert(
"image".to_string(),
YamlValue::String("redis:6".to_string()),
);
let mut redis_healthcheck = IndexMap::new();
redis_healthcheck.insert(
"test".to_string(),
YamlValue::String("redis-cli ping".to_string()),
);
redis_healthcheck.insert(
"interval".to_string(),
YamlValue::String("5s".to_string()),
);
redis_healthcheck.insert(
"timeout".to_string(),
YamlValue::String("1s".to_string()),
);
redis_healthcheck.insert(
"retries".to_string(),
YamlValue::String("5".to_string()),
);
redis.insert("healthcheck".to_string(), YamlValue::Dict(redis_healthcheck));
services.insert("langgraph-redis".to_string(), YamlValue::Dict(redis));
// Postgres service (if needed)
if include_db {
let mut postgres = IndexMap::new();
postgres.insert(
"image".to_string(),
YamlValue::String("pgvector/pgvector:pg16".to_string()),
);
postgres.insert(
"ports".to_string(),
YamlValue::List(vec!["\"5433:5432\"".to_string()]),
);
let mut pg_env = IndexMap::new();
pg_env.insert(
"POSTGRES_DB".to_string(),
YamlValue::String("postgres".to_string()),
);
pg_env.insert(
"POSTGRES_USER".to_string(),
YamlValue::String("postgres".to_string()),
);
pg_env.insert(
"POSTGRES_PASSWORD".to_string(),
YamlValue::String("postgres".to_string()),
);
postgres.insert("environment".to_string(), YamlValue::Dict(pg_env));
postgres.insert(
"command".to_string(),
YamlValue::List(vec![
"postgres".to_string(),
"-c".to_string(),
"shared_preload_libraries=vector".to_string(),
]),
);
postgres.insert(
"volumes".to_string(),
YamlValue::List(vec![
"langgraph-data:/var/lib/postgresql/data".to_string(),
]),
);
let mut pg_healthcheck = IndexMap::new();
pg_healthcheck.insert(
"test".to_string(),
YamlValue::String("pg_isready -U postgres".to_string()),
);
pg_healthcheck.insert(
"start_period".to_string(),
YamlValue::String("10s".to_string()),
);
pg_healthcheck.insert(
"timeout".to_string(),
YamlValue::String("1s".to_string()),
);
pg_healthcheck.insert(
"retries".to_string(),
YamlValue::String("5".to_string()),
);
if capabilities.healthcheck_start_interval {
pg_healthcheck.insert(
"interval".to_string(),
YamlValue::String("60s".to_string()),
);
pg_healthcheck.insert(
"start_interval".to_string(),
YamlValue::String("1s".to_string()),
);
} else {
pg_healthcheck.insert(
"interval".to_string(),
YamlValue::String("5s".to_string()),
);
}
postgres.insert("healthcheck".to_string(), YamlValue::Dict(pg_healthcheck));
services.insert("langgraph-postgres".to_string(), YamlValue::Dict(postgres));
}
// Debugger service (if port specified)
if let Some(dbg_port) = debugger_port {
let debugger = debugger_compose(Some(dbg_port), debugger_base_url);
for (k, v) in debugger {
services.insert(k, v);
}
}
// LangGraph API service
let mut api = IndexMap::new();
api.insert(
"ports".to_string(),
YamlValue::List(vec![format!("\"{port}:8000\"")]),
);
let mut api_depends = IndexMap::new();
let mut redis_condition = IndexMap::new();
redis_condition.insert(
"condition".to_string(),
YamlValue::String("service_healthy".to_string()),
);
api_depends.insert(
"langgraph-redis".to_string(),
YamlValue::Dict(redis_condition),
);
api.insert("depends_on".to_string(), YamlValue::Dict(api_depends.clone()));
let mut api_env = IndexMap::new();
api_env.insert(
"REDIS_URI".to_string(),
YamlValue::String("redis://langgraph-redis:6379".to_string()),
);
api_env.insert(
"POSTGRES_URI".to_string(),
YamlValue::String(postgres_uri.to_string()),
);
api.insert("environment".to_string(), YamlValue::Dict(api_env));
if let Some(img) = image {
api.insert(
"image".to_string(),
YamlValue::String(img.to_string()),
);
}
// Add postgres dependency for API service
if include_db {
if let YamlValue::Dict(ref mut deps) = api.get_mut("depends_on").unwrap() {
let mut pg_condition = IndexMap::new();
pg_condition.insert(
"condition".to_string(),
YamlValue::String("service_healthy".to_string()),
);
deps.insert(
"langgraph-postgres".to_string(),
YamlValue::Dict(pg_condition),
);
}
}
// Healthcheck for API service
if capabilities.healthcheck_start_interval {
let mut api_healthcheck = IndexMap::new();
api_healthcheck.insert(
"test".to_string(),
YamlValue::String("python /api/healthcheck.py".to_string()),
);
api_healthcheck.insert(
"interval".to_string(),
YamlValue::String("60s".to_string()),
);
api_healthcheck.insert(
"start_interval".to_string(),
YamlValue::String("1s".to_string()),
);
api_healthcheck.insert(
"start_period".to_string(),
YamlValue::String("10s".to_string()),
);
api.insert("healthcheck".to_string(), YamlValue::Dict(api_healthcheck));
}
services.insert("langgraph-api".to_string(), YamlValue::Dict(api));
// Build final compose dict
let mut compose_dict = IndexMap::new();
if include_db {
let mut volumes = IndexMap::new();
let mut vol_config = IndexMap::new();
vol_config.insert(
"driver".to_string(),
YamlValue::String("local".to_string()),
);
volumes.insert("langgraph-data".to_string(), YamlValue::Dict(vol_config));
compose_dict.insert("volumes".to_string(), YamlValue::Dict(volumes));
}
compose_dict.insert("services".to_string(), YamlValue::Dict(services));
compose_dict
}
/// Create a docker compose file as a string.
pub fn compose(
capabilities: &DockerCapabilities,
port: u16,
debugger_port: Option<u16>,
debugger_base_url: Option<&str>,
postgres_uri: Option<&str>,
image: Option<&str>,
base_image: Option<&str>,
api_version: Option<&str>,
) -> String {
let compose_dict = compose_as_dict(
capabilities,
port,
debugger_port,
debugger_base_url,
postgres_uri,
image,
base_image,
api_version,
);
dict_to_yaml(&compose_dict, 0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docker::capabilities::{ComposeType, Version};
use crate::util::clean_empty_lines;
fn default_capabilities() -> DockerCapabilities {
DockerCapabilities {
version_docker: Version::new(26, 1, 1),
version_compose: Version::new(2, 27, 0),
healthcheck_start_interval: false,
compose_type: ComposeType::Plugin,
}
}
#[test]
fn test_dict_to_yaml_simple() {
let mut d = IndexMap::new();
d.insert(
"key1".to_string(),
YamlValue::String("value1".to_string()),
);
d.insert(
"key2".to_string(),
YamlValue::String("value2".to_string()),
);
let result = dict_to_yaml(&d, 0);
// Top-level keys get an extra newline separator between them
assert_eq!(result, "key1: value1\n\nkey2: value2\n");
}
#[test]
fn test_dict_to_yaml_nested() {
let mut inner = IndexMap::new();
inner.insert(
"nested_key".to_string(),
YamlValue::String("nested_value".to_string()),
);
let mut d = IndexMap::new();
d.insert("outer".to_string(), YamlValue::Dict(inner));
let result = dict_to_yaml(&d, 0);
assert_eq!(result, "outer:\n nested_key: nested_value\n");
}
#[test]
fn test_dict_to_yaml_list() {
let mut d = IndexMap::new();
d.insert(
"items".to_string(),
YamlValue::List(vec!["a".to_string(), "b".to_string()]),
);
let result = dict_to_yaml(&d, 0);
assert_eq!(result, "items:\n - a\n - b\n");
}
#[test]
fn test_compose_with_no_debugger_and_custom_db() {
let port = 8123;
let custom_postgres_uri = "custom_postgres_uri";
let actual = compose(
&default_capabilities(),
port,
None,
None,
Some(custom_postgres_uri),
None,
None,
None,
);
let expected = format!(
"services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {custom_postgres_uri}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_no_debugger_and_custom_db_with_healthcheck() {
let port = 8123;
let custom_postgres_uri = "custom_postgres_uri";
let mut caps = default_capabilities();
caps.healthcheck_start_interval = true;
let actual = compose(
&caps,
port,
None,
None,
Some(custom_postgres_uri),
None,
None,
None,
);
let expected = format!(
"services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {custom_postgres_uri}\n\
\x20 healthcheck:\n\
\x20 test: python /api/healthcheck.py\n\
\x20 interval: 60s\n\
\x20 start_interval: 1s\n\
\x20 start_period: 10s"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_debugger_and_custom_db() {
let port = 8123;
let custom_postgres_uri = "custom_postgres_uri";
let actual = compose(
&default_capabilities(),
port,
None,
None,
Some(custom_postgres_uri),
None,
None,
None,
);
let expected = format!(
"services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {custom_postgres_uri}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_debugger_and_default_db() {
let port = 8123;
let actual = compose(
&default_capabilities(),
port,
None,
None,
None,
None,
None,
None,
);
let expected = format!(
"volumes:\n\
\x20 langgraph-data:\n\
\x20 driver: local\n\
services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-postgres:\n\
\x20 image: pgvector/pgvector:pg16\n\
\x20 ports:\n\
\x20 - \"5433:5432\"\n\
\x20 environment:\n\
\x20 POSTGRES_DB: postgres\n\
\x20 POSTGRES_USER: postgres\n\
\x20 POSTGRES_PASSWORD: postgres\n\
\x20 command:\n\
\x20 - postgres\n\
\x20 - -c\n\
\x20 - shared_preload_libraries=vector\n\
\x20 volumes:\n\
\x20 - langgraph-data:/var/lib/postgresql/data\n\
\x20 healthcheck:\n\
\x20 test: pg_isready -U postgres\n\
\x20 start_period: 10s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 interval: 5s\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 langgraph-postgres:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_api_version() {
let port = 8123;
let actual = compose(
&default_capabilities(),
port,
None,
None,
None,
None,
None,
Some("0.2.74"),
);
let expected = format!(
"volumes:\n\
\x20 langgraph-data:\n\
\x20 driver: local\n\
services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-postgres:\n\
\x20 image: pgvector/pgvector:pg16\n\
\x20 ports:\n\
\x20 - \"5433:5432\"\n\
\x20 environment:\n\
\x20 POSTGRES_DB: postgres\n\
\x20 POSTGRES_USER: postgres\n\
\x20 POSTGRES_PASSWORD: postgres\n\
\x20 command:\n\
\x20 - postgres\n\
\x20 - -c\n\
\x20 - shared_preload_libraries=vector\n\
\x20 volumes:\n\
\x20 - langgraph-data:/var/lib/postgresql/data\n\
\x20 healthcheck:\n\
\x20 test: pg_isready -U postgres\n\
\x20 start_period: 10s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 interval: 5s\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 langgraph-postgres:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_api_version_and_base_image() {
let port = 8123;
let actual = compose(
&default_capabilities(),
port,
None,
None,
None,
None,
Some("my-registry/custom-api"),
Some("1.0.0"),
);
let expected = format!(
"volumes:\n\
\x20 langgraph-data:\n\
\x20 driver: local\n\
services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-postgres:\n\
\x20 image: pgvector/pgvector:pg16\n\
\x20 ports:\n\
\x20 - \"5433:5432\"\n\
\x20 environment:\n\
\x20 POSTGRES_DB: postgres\n\
\x20 POSTGRES_USER: postgres\n\
\x20 POSTGRES_PASSWORD: postgres\n\
\x20 command:\n\
\x20 - postgres\n\
\x20 - -c\n\
\x20 - shared_preload_libraries=vector\n\
\x20 volumes:\n\
\x20 - langgraph-data:/var/lib/postgresql/data\n\
\x20 healthcheck:\n\
\x20 test: pg_isready -U postgres\n\
\x20 start_period: 10s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 interval: 5s\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 langgraph-postgres:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_api_version_and_custom_postgres() {
let port = 8123;
let custom_postgres_uri = "postgresql://user:pass@external-db:5432/mydb";
let actual = compose(
&default_capabilities(),
port,
None,
None,
Some(custom_postgres_uri),
None,
None,
Some("0.2.74"),
);
let expected = format!(
"services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {custom_postgres_uri}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
#[test]
fn test_compose_with_api_version_and_debugger() {
let port = 8123;
let debugger_port = 8001;
let actual = compose(
&default_capabilities(),
port,
Some(debugger_port),
None,
None,
None,
None,
Some("0.2.74"),
);
let expected = format!(
"volumes:\n\
\x20 langgraph-data:\n\
\x20 driver: local\n\
services:\n\
\x20 langgraph-redis:\n\
\x20 image: redis:6\n\
\x20 healthcheck:\n\
\x20 test: redis-cli ping\n\
\x20 interval: 5s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 langgraph-postgres:\n\
\x20 image: pgvector/pgvector:pg16\n\
\x20 ports:\n\
\x20 - \"5433:5432\"\n\
\x20 environment:\n\
\x20 POSTGRES_DB: postgres\n\
\x20 POSTGRES_USER: postgres\n\
\x20 POSTGRES_PASSWORD: postgres\n\
\x20 command:\n\
\x20 - postgres\n\
\x20 - -c\n\
\x20 - shared_preload_libraries=vector\n\
\x20 volumes:\n\
\x20 - langgraph-data:/var/lib/postgresql/data\n\
\x20 healthcheck:\n\
\x20 test: pg_isready -U postgres\n\
\x20 start_period: 10s\n\
\x20 timeout: 1s\n\
\x20 retries: 5\n\
\x20 interval: 5s\n\
\x20 langgraph-debugger:\n\
\x20 image: langchain/langgraph-debugger\n\
\x20 restart: on-failure\n\
\x20 depends_on:\n\
\x20 langgraph-postgres:\n\
\x20 condition: service_healthy\n\
\x20 ports:\n\
\x20 - \"{debugger_port}:3968\"\n\
\x20 langgraph-api:\n\
\x20 ports:\n\
\x20 - \"{port}:8000\"\n\
\x20 depends_on:\n\
\x20 langgraph-redis:\n\
\x20 condition: service_healthy\n\
\x20 langgraph-postgres:\n\
\x20 condition: service_healthy\n\
\x20 environment:\n\
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
);
assert_eq!(clean_empty_lines(&actual), expected);
}
}
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
pub mod capabilities;
pub mod compose;
pub mod dockerfile;
-211
View File
@@ -1,211 +0,0 @@
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
/// Run a command synchronously, optionally piping stdin, and capturing stdout/stderr.
///
/// If `verbose` is true, the command is echoed to stdout before execution.
///
/// Returns `(Option<stdout>, Option<stderr>)` on success, or an error message on failure.
pub fn run_command(
cmd: &str,
args: &[&str],
input: Option<&str>,
verbose: bool,
) -> Result<(Option<String>, Option<String>), String> {
if verbose {
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
if let Some(inp) = input {
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
println!("{} <\n{}", cmd_str, filtered.join("\n"));
} else {
println!("{cmd_str}");
}
}
let stdin_cfg = if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut child = Command::new(cmd)
.args(args)
.stdin(stdin_cfg)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
if let Some(input_data) = input {
if let Some(ref mut stdin_handle) = child.stdin {
stdin_handle
.write_all(input_data.as_bytes())
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
}
// Drop stdin to signal EOF
drop(child.stdin.take());
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
if !output.status.success() {
let code = output.status.code().unwrap_or(-1);
// 130 = SIGINT (Ctrl-C), not an error
if code == 130 {
return Ok((None, None));
}
let stdout_str = String::from_utf8_lossy(&output.stdout);
let stderr_str = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"Command `{cmd}` exited with code {code}\nstdout: {stdout_str}\nstderr: {stderr_str}"
));
}
let stdout = if output.stdout.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&output.stdout).to_string())
};
let stderr = if output.stderr.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&output.stderr).to_string())
};
Ok((stdout, stderr))
}
/// Run a command and stream its stdout/stderr to the parent process in real-time.
///
/// This is useful for long-running commands like `docker compose up` where we
/// want to see output as it happens.
pub fn run_command_streaming(
cmd: &str,
args: &[&str],
input: Option<&str>,
verbose: bool,
) -> Result<(), String> {
if verbose {
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
if let Some(inp) = input {
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
println!("{} <\n{}", cmd_str, filtered.join("\n"));
} else {
println!("{cmd_str}");
}
}
let stdin_cfg = if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut child = Command::new(cmd)
.args(args)
.stdin(stdin_cfg)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
if let Some(input_data) = input {
if let Some(ref mut stdin_handle) = child.stdin {
stdin_handle
.write_all(input_data.as_bytes())
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
}
drop(child.stdin.take());
}
let status = child
.wait()
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
if !status.success() {
let code = status.code().unwrap_or(-1);
if code == 130 {
return Ok(());
}
return Err(format!("Command `{cmd}` exited with code {code}"));
}
Ok(())
}
/// Run a command, streaming stderr to the parent process, while intercepting
/// stdout line-by-line through a callback. Each stdout line is forwarded to
/// the parent's stdout after the callback processes it.
pub fn run_command_streaming_with_callback<F>(
cmd: &str,
args: &[&str],
input: Option<&str>,
verbose: bool,
mut on_stdout: F,
) -> Result<(), String>
where
F: FnMut(&str),
{
if verbose {
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
if let Some(inp) = input {
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
println!("{} <\n{}", cmd_str, filtered.join("\n"));
} else {
println!("{cmd_str}");
}
}
let stdin_cfg = if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut child = Command::new(cmd)
.args(args)
.stdin(stdin_cfg)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
if let Some(input_data) = input {
if let Some(ref mut stdin_handle) = child.stdin {
stdin_handle
.write_all(input_data.as_bytes())
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
}
drop(child.stdin.take());
}
// Read stdout line by line, forward to our stdout, and call callback
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) => {
println!("{line}");
on_stdout(&line);
}
Err(_) => break,
}
}
}
let status = child
.wait()
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
if !status.success() {
let code = status.code().unwrap_or(-1);
if code == 130 {
return Ok(());
}
return Err(format!("Command `{cmd}` exited with code {code}"));
}
Ok(())
}
-305
View File
@@ -1,305 +0,0 @@
mod analytics;
mod commands;
mod config;
mod constants;
mod docker;
mod exec;
mod progress;
mod templates;
mod util;
use clap::{Parser, Subcommand};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
#[command(name = "langgraph", version = VERSION, about = "LangGraph CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Launch LangGraph API server with Docker
Up {
/// Path to configuration file declaring dependencies, graphs and environment variables
#[arg(short, long, default_value = "langgraph.json")]
config: String,
/// Port to expose
#[arg(short, long, default_value_t = 8123)]
port: u16,
/// Path to docker-compose.yml file with additional services
#[arg(short, long)]
docker_compose: Option<String>,
/// Show detailed output
#[arg(short, long)]
verbose: bool,
/// Restart on file changes using docker compose watch
#[arg(short, long)]
watch: bool,
/// Recreate containers even if configuration hasn't changed
#[arg(long)]
recreate: bool,
/// Skip pulling latest images before running
#[arg(long)]
no_pull: bool,
/// Wait for services to be healthy before returning
#[arg(long)]
wait: bool,
/// Port to expose the debugger on
#[arg(long)]
debugger_port: Option<u16>,
/// Base URL for the debugger
#[arg(long)]
debugger_base_url: Option<String>,
/// Postgres connection URI
#[arg(long)]
postgres_uri: Option<String>,
/// API version of the LangGraph server
#[arg(long)]
api_version: Option<String>,
/// Pre-built image to use instead of building
#[arg(long)]
image: Option<String>,
/// Base image for the LangGraph API server
#[arg(long)]
base_image: Option<String>,
},
/// Build LangGraph API server Docker image
Build {
/// Path to configuration file
#[arg(short, long, default_value = "langgraph.json")]
config: String,
/// Tag for the docker image
#[arg(short, long)]
tag: String,
/// Skip pulling latest images before building
#[arg(long)]
no_pull: bool,
/// Base image for the LangGraph API server
#[arg(long)]
base_image: Option<String>,
/// API version of the LangGraph server
#[arg(long)]
api_version: Option<String>,
/// Custom install command
#[arg(long)]
install_command: Option<String>,
/// Custom build command
#[arg(long)]
build_command: Option<String>,
/// Additional arguments to pass to docker build
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
docker_build_args: Vec<String>,
},
/// Generate a Dockerfile for the LangGraph API server
Dockerfile {
/// Path to save the generated Dockerfile
save_path: String,
/// Path to configuration file
#[arg(short, long, default_value = "langgraph.json")]
config: String,
/// Add docker-compose.yml, .env, and .dockerignore files
#[arg(long)]
add_docker_compose: bool,
/// Base image for the LangGraph API server
#[arg(long)]
base_image: Option<String>,
/// API version of the LangGraph server
#[arg(long)]
api_version: Option<String>,
},
/// Run LangGraph API server in development mode
Dev {
/// Network interface to bind to
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Port number
#[arg(long, default_value_t = 2024)]
port: u16,
/// Disable automatic reloading
#[arg(long)]
no_reload: bool,
/// Path to configuration file
#[arg(short, long, default_value = "langgraph.json")]
config: String,
/// Max concurrent jobs per worker
#[arg(long)]
n_jobs_per_worker: Option<u32>,
/// Skip opening browser
#[arg(long)]
no_browser: bool,
/// Enable remote debugging on specified port
#[arg(long)]
debug_port: Option<u16>,
/// Wait for debugger client to connect
#[arg(long)]
wait_for_client: bool,
/// URL of LangGraph Studio
#[arg(long)]
studio_url: Option<String>,
/// Allow synchronous I/O blocking operations
#[arg(long)]
allow_blocking: bool,
/// Expose via public tunnel
#[arg(long)]
tunnel: bool,
/// Log level for the API server
#[arg(long, default_value = "WARNING")]
server_log_level: String,
},
/// Create a new LangGraph project from a template
New {
/// Path to create the project
path: Option<String>,
/// Template to use
#[arg(long)]
template: Option<String>,
},
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Up {
config,
port,
docker_compose,
verbose,
watch,
recreate,
no_pull,
wait,
debugger_port,
debugger_base_url,
postgres_uri,
api_version,
image,
base_image,
} => commands::up::run(
&config,
port,
docker_compose.as_deref(),
verbose,
watch,
recreate,
!no_pull,
wait,
debugger_port,
debugger_base_url.as_deref(),
postgres_uri.as_deref(),
api_version.as_deref(),
image.as_deref(),
base_image.as_deref(),
),
Commands::Build {
config,
tag,
no_pull,
base_image,
api_version,
install_command,
build_command,
docker_build_args,
} => commands::build_cmd::run(
&config,
&tag,
!no_pull,
base_image.as_deref(),
api_version.as_deref(),
install_command.as_deref(),
build_command.as_deref(),
&docker_build_args,
),
Commands::Dockerfile {
save_path,
config,
add_docker_compose,
base_image,
api_version,
} => commands::dockerfile::run(
&save_path,
&config,
add_docker_compose,
base_image.as_deref(),
api_version.as_deref(),
),
Commands::Dev {
host,
port,
no_reload,
config,
n_jobs_per_worker,
no_browser,
debug_port,
wait_for_client,
studio_url,
allow_blocking,
tunnel,
server_log_level,
} => commands::dev::run(
&host,
port,
no_reload,
&config,
n_jobs_per_worker,
no_browser,
debug_port,
wait_for_client,
studio_url.as_deref(),
allow_blocking,
tunnel,
&server_log_level,
),
Commands::New { path, template } => {
commands::new::run(path.as_deref(), template.as_deref())
}
};
if let Err(e) = result {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
-43
View File
@@ -1,43 +0,0 @@
use indicatif::{ProgressBar, ProgressStyle};
/// Terminal spinner using indicatif.
///
/// Wraps an indicatif spinner that displays an animated progress indicator
/// with a configurable message.
pub struct Progress {
spinner: ProgressBar,
}
impl Progress {
/// Create a new spinner with the given initial message.
pub fn new(message: &str) -> Self {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.tick_strings(&["|", "/", "-", "\\", ""])
.template("{spinner} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
spinner.set_message(message.to_string());
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
Self { spinner }
}
/// Update the spinner message.
///
/// If the message is empty, the spinner is effectively hidden but still running.
pub fn set_message(&self, msg: &str) {
self.spinner.set_message(msg.to_string());
}
/// Stop the spinner and clear it from the terminal.
pub fn finish(&self) {
self.spinner.finish_and_clear();
}
}
impl Drop for Progress {
fn drop(&mut self) {
self.spinner.finish_and_clear();
}
}
-257
View File
@@ -1,257 +0,0 @@
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::Path;
use console::style;
use dialoguer::{Input, Select};
/// A project template definition.
pub struct Template {
pub name: &'static str,
pub description: &'static str,
pub python: &'static str,
pub js: &'static str,
}
/// All available project templates.
pub const TEMPLATES: &[Template] = &[
Template {
name: "New LangGraph Project",
description: "A simple, minimal chatbot with memory.",
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",
},
Template {
name: "ReAct Agent",
description: "A simple agent that can be flexibly extended to many tools.",
python: "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
},
Template {
name: "Memory Agent",
description: "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
python: "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
},
Template {
name: "Retrieval Agent",
description: "An agent that includes a retrieval-based question-answering system.",
python: "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
},
Template {
name: "Data-enrichment Agent",
description: "An agent that performs web searches and organizes its findings into a structured format.",
python: "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
js: "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
},
];
/// Mapping from template ID (e.g., "react-agent-python") to (template index, language, url).
pub fn build_template_id_map() -> HashMap<String, (usize, &'static str, &'static str)> {
let mut map = HashMap::new();
for (idx, tmpl) in TEMPLATES.iter().enumerate() {
let base = tmpl.name.to_lowercase().replace(' ', "-");
map.insert(
format!("{base}-python"),
(idx, "python", tmpl.python),
);
map.insert(format!("{base}-js"), (idx, "js", tmpl.js));
}
map
}
/// Get a sorted list of all valid template IDs.
pub fn template_ids() -> Vec<String> {
let map = build_template_id_map();
let mut ids: Vec<String> = map.keys().cloned().collect();
ids.sort();
ids
}
/// Interactively choose a template. Returns the download URL.
fn choose_template() -> Result<String, String> {
eprintln!("{}", style("Please select a template:").bold().yellow());
let items: Vec<String> = TEMPLATES
.iter()
.map(|t| format!("{} - {}", t.name, t.description))
.collect();
let selection = Select::new()
.with_prompt("Select a template")
.items(&items)
.default(0)
.interact()
.map_err(|e| format!("Template selection failed: {e}"))?;
let tmpl = &TEMPLATES[selection];
eprintln!(
"\n{}",
style(format!("You selected: {} - {}", tmpl.name, tmpl.description)).green()
);
let lang_items = vec!["Python", "JS/TS"];
let lang_choice = Select::new()
.with_prompt("Choose language")
.items(&lang_items)
.default(0)
.interact()
.map_err(|e| format!("Language selection failed: {e}"))?;
let url = if lang_choice == 0 {
tmpl.python
} else {
tmpl.js
};
Ok(url.to_string())
}
/// Download a zip archive from `url` and extract to `path`.
fn download_repo(url: &str, path: &str) -> Result<(), String> {
eprintln!(
"{}",
style("Downloading repository as a ZIP archive...").yellow()
);
eprintln!("{}", style(format!("URL: {url}")).yellow());
// Use blocking reqwest to download
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.map_err(|e| format!("Failed to download repository: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Failed to download repository. HTTP status: {}",
response.status()
));
}
let bytes = response
.bytes()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let target_path = Path::new(path);
if !target_path.exists() {
std::fs::create_dir_all(target_path)
.map_err(|e| format!("Failed to create directory {path}: {e}"))?;
}
let cursor = Cursor::new(bytes);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to open ZIP archive: {e}"))?;
// Extract all files
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
let name = file.name().to_string();
// Strip the top-level directory (e.g., "repo-main/")
let stripped = match name.split_once('/') {
Some((_, rest)) if !rest.is_empty() => rest.to_string(),
_ => continue, // Skip the top-level directory entry itself
};
let out_path = target_path.join(&stripped);
if file.is_dir() {
std::fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create directory {}: {e}", out_path.display()))?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create directory {}: {e}", parent.display())
})?;
}
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.map_err(|e| format!("Failed to read file from ZIP: {e}"))?;
std::fs::write(&out_path, &buf)
.map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?;
}
}
eprintln!(
"{}",
style(format!("Downloaded and extracted repository to {path}")).green()
);
Ok(())
}
/// Create a new LangGraph project at the specified path using the chosen template.
///
/// If `path` is None, the user is prompted interactively.
/// If `template` is None, the user picks from an interactive menu.
pub fn create_new(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
// Prompt for path if not provided
let path = match path {
Some(p) => p.to_string(),
None => {
let input: String = Input::new()
.with_prompt("Please specify the path to create the application")
.default(".".to_string())
.interact_text()
.map_err(|e| format!("Input failed: {e}"))?;
input
}
};
let abs_path = std::path::Path::new(&path)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(&path));
let abs_path_str = abs_path.to_string_lossy().to_string();
// If the path doesn't exist yet, that's fine. But if it exists and is non-empty, abort.
if abs_path.exists() {
let entries = std::fs::read_dir(&abs_path)
.map_err(|e| format!("Could not read directory {abs_path_str}: {e}"))?;
if entries.count() > 0 {
return Err(
"The specified directory already exists and is not empty. \
Aborting to prevent overwriting files."
.to_string(),
);
}
}
// Get template URL either from command-line argument or through interactive selection
let template_url = if let Some(tmpl_id) = template {
let id_map = build_template_id_map();
if let Some((_idx, _lang, url)) = id_map.get(tmpl_id) {
url.to_string()
} else {
let ids = template_ids();
let mut options = String::new();
for id in &ids {
let (_idx, _lang, _url) = id_map.get(id.as_str()).unwrap();
let tmpl = &TEMPLATES[*_idx];
options.push_str(&format!("- {id}: {}\n", tmpl.description));
}
return Err(format!(
"Template '{tmpl_id}' not found.\n\
Please select from the available options:\n{options}"
));
}
} else {
choose_template()?
};
// Download and extract the template
download_repo(&template_url, &abs_path_str)?;
eprintln!(
"{}",
style(format!("New project created at {abs_path_str}"))
.green()
.bold()
);
Ok(())
}
-57
View File
@@ -1,57 +0,0 @@
use console::style;
/// Remove empty lines from a string.
pub fn clean_empty_lines(input: &str) -> String {
input
.lines()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
/// Show warning if image_distro is not set to 'wolfi'.
pub fn warn_non_wolfi_distro(config: &serde_json::Value) {
let image_distro = config
.get("image_distro")
.and_then(|v| v.as_str())
.unwrap_or("debian");
if image_distro != "wolfi" {
eprintln!(
"{}",
style(
"Warning: Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
)
.yellow()
.bold()
);
eprintln!(
"{}",
style(
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
)
.yellow()
);
eprintln!(
"{}",
style(
" To switch, add '\"image_distro\": \"wolfi\"' to your langgraph.json config file."
)
.yellow()
);
eprintln!();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_empty_lines() {
assert_eq!(clean_empty_lines("line1\n\nline2\n\nline3"), "line1\nline2\nline3");
assert_eq!(clean_empty_lines("line1\nline2\nline3"), "line1\nline2\nline3");
assert_eq!(clean_empty_lines("\n\n\n"), "");
assert_eq!(clean_empty_lines(""), "");
}
}
+3 -3
View File
@@ -1071,15 +1071,15 @@ wheels = [
[[package]]
name = "langgraph-sdk"
version = "0.3.5"
version = "0.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", marker = "python_full_version >= '3.11'" },
{ name = "orjson", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/2b/2dae368ac76e315197f07ab58077aadf20833c226fbfd450d71745850314/langgraph_sdk-0.3.5.tar.gz", hash = "sha256:64669e9885a908578eed921ef9a8e52b8d0cd38db1e3e5d6d299d4e6f8830ac0", size = 177470, upload-time = "2026-02-10T16:56:09.18Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/ec/477fa8b408f948b145d90fd935c0a9f37945fa5ec1dfabfc71e7cafba6d8/langgraph_sdk-0.3.6.tar.gz", hash = "sha256:7650f607f89c1586db5bee391b1a8754cbe1fc83b721ff2f1450f8906e790bd7", size = 182666, upload-time = "2026-02-14T19:46:03.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d5/a14d957c515ba7a9713bf0f03f2b9277979c403bc50f829bdfd54ae7dc9e/langgraph_sdk-0.3.5-py3-none-any.whl", hash = "sha256:bcfa1dcbddadb604076ce46f5e08969538735e5ac47fa863d4fac5a512dab5c9", size = 70851, upload-time = "2026-02-10T16:56:07.983Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/12508e12652edd1874327271a5a8834c728a605f53a1a1c945f13ab69664/langgraph_sdk-0.3.6-py3-none-any.whl", hash = "sha256:7df2fd552ad7262d0baf8e1f849dce1d62186e76dcdd36db9dc5bdfa5c3fc20f", size = 88277, upload-time = "2026-02-14T19:46:02.48Z" },
]
[[package]]
@@ -728,7 +728,6 @@ async def _acall_impl(
)
else:
fut.set_result(None)
futures()[fut] = next_task # type: ignore[index]
else:
# schedule the next task
fut = cast(
+378 -8
View File
@@ -20,10 +20,13 @@ from dataclasses import is_dataclass
from functools import partial
from inspect import isclass
from typing import (
TYPE_CHECKING,
Any,
Generic,
Literal,
cast,
get_type_hints,
overload,
)
from uuid import UUID, uuid5
@@ -48,6 +51,9 @@ from langgraph.store.base import BaseStore
from pydantic import BaseModel, TypeAdapter
from typing_extensions import Self, Unpack, deprecated, is_typeddict
if TYPE_CHECKING:
from langchain_core.messages import AnyMessage
from langgraph._internal._config import (
ensure_config,
merge_configs,
@@ -2404,6 +2410,186 @@ class Pregel(
durability,
)
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[OutputT]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[dict[str, Any]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[AnyMessage, dict[str, Any]]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[Any]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[tuple[str, ...], OutputT]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[tuple[str, ...], Any]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[str, Any]]: ...
@overload
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Iterator[tuple[tuple[str, ...], str, Any]]: ...
def stream(
self,
input: InputT | Command | None,
@@ -2678,6 +2864,186 @@ class Pregel(
run_manager.on_chain_error(e)
raise
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[OutputT]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[dict[str, Any]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[AnyMessage, dict[str, Any]]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[Any]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[tuple[str, ...], OutputT]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[tuple[str, ...], Any]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[False] = False,
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[str, Any]]: ...
@overload
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
print_mode: StreamMode | Sequence[StreamMode] = (),
output_keys: str | Sequence[str] | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
durability: Durability | None = None,
subgraphs: Literal[True],
debug: bool | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]: ...
async def astream(
self,
input: InputT | Command | None,
@@ -3068,13 +3434,15 @@ class Pregel(
chunks: list[dict[str, Any] | Any] = []
interrupts: list[Interrupt] = []
for chunk in self.stream(
for chunk in self.stream( # type: ignore[misc]
input,
config,
context=context,
stream_mode=["updates", "values"]
if stream_mode == "values"
else stream_mode,
stream_mode=(
["updates", "values"] # type: ignore[arg-type]
if stream_mode == "values"
else stream_mode
),
print_mode=print_mode,
output_keys=output_keys,
interrupt_before=interrupt_before,
@@ -3158,13 +3526,15 @@ class Pregel(
chunks: list[dict[str, Any] | Any] = []
interrupts: list[Interrupt] = []
async for chunk in self.astream(
async for chunk in self.astream( # type: ignore[misc]
input,
config,
context=context,
stream_mode=["updates", "values"]
if stream_mode == "values"
else stream_mode,
stream_mode=(
["updates", "values"] # type: ignore[arg-type]
if stream_mode == "values"
else stream_mode
),
print_mode=print_mode,
output_keys=output_keys,
interrupt_before=interrupt_before,
+284 -1
View File
@@ -2,13 +2,16 @@ from __future__ import annotations
from abc import abstractmethod
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from typing import Any, Generic, cast
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.runnables.graph import Graph as DrawableGraph
from typing_extensions import Self
from langgraph.types import All, Command, StateSnapshot, StateUpdate, StreamMode
if TYPE_CHECKING:
from langchain_core.messages import AnyMessage
from langgraph.typing import ContextT, InputT, OutputT, StateT
__all__ = ("PregelProtocol", "StreamProtocol")
@@ -96,6 +99,146 @@ class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, Ou
as_node: str | None = None,
) -> RunnableConfig: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> Iterator[OutputT]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> Iterator[dict[str, Any]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> Iterator[tuple[AnyMessage, dict[str, Any]]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> Iterator[Any]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> Iterator[tuple[tuple[str, ...], OutputT]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> Iterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> Iterator[tuple[tuple[str, ...], Any]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> Iterator[tuple[str, Any]]: ...
@overload
@abstractmethod
def stream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> Iterator[tuple[tuple[str, ...], str, Any]]: ...
@abstractmethod
def stream(
self,
@@ -109,6 +252,146 @@ class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, Ou
subgraphs: bool = False,
) -> Iterator[dict[str, Any] | Any]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> AsyncIterator[OutputT]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> AsyncIterator[dict[str, Any]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> AsyncIterator[tuple[AnyMessage, dict[str, Any]]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> AsyncIterator[Any]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> AsyncIterator[tuple[tuple[str, ...], OutputT]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> AsyncIterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> AsyncIterator[tuple[tuple[str, ...], Any]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
) -> AsyncIterator[tuple[str, Any]]: ...
@overload
@abstractmethod
def astream(
self,
input: InputT | Command | None,
config: RunnableConfig | None = None,
*,
context: ContextT | None = None,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]: ...
@abstractmethod
def astream(
self,
+305
View File
@@ -4,9 +4,11 @@ import logging
from collections.abc import AsyncIterator, Iterator, Sequence
from dataclasses import asdict
from typing import (
TYPE_CHECKING,
Any,
Literal,
cast,
overload,
)
from uuid import UUID
@@ -41,6 +43,9 @@ from langgraph_sdk.schema import (
)
from typing_extensions import Self
if TYPE_CHECKING:
from langchain_core.messages import AnyMessage
from langgraph._internal._config import merge_configs
from langgraph._internal._constants import (
CONF,
@@ -682,6 +687,156 @@ class RemoteGraph(PregelProtocol):
updated_stream_modes.remove("events")
return (updated_stream_modes, requested_stream_modes, req_single, stream)
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[dict[str, Any]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[dict[str, Any]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[AnyMessage, dict[str, Any]]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[Any]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[tuple[str, ...], Any]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[str, Any]]: ...
@overload
def stream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> Iterator[tuple[tuple[str, ...], str, Any]]: ...
def stream(
self,
input: dict[str, Any] | Any,
@@ -792,6 +947,156 @@ class RemoteGraph(PregelProtocol):
else:
yield chunk
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[dict[str, Any]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[dict[str, Any]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[AnyMessage, dict[str, Any]]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[Any]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["values"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["updates"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["messages"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: Literal["custom"],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[tuple[str, ...], Any]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[False] = False,
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[str, Any]]: ...
@overload
def astream(
self,
input: dict[str, Any] | Any,
config: RunnableConfig | None = None,
*,
stream_mode: list[StreamMode],
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
subgraphs: Literal[True],
headers: dict[str, str] | None = None,
params: QueryParamTypes | None = None,
**kwargs: Any,
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]: ...
async def astream(
self,
input: dict[str, Any] | Any,
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.0.8"
version = "1.0.9"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -27,7 +27,7 @@ dependencies = [
"langchain-core>=0.1",
"langgraph-checkpoint>=2.1.0,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-prebuilt>=1.0.7,<1.1.0",
"langgraph-prebuilt>=1.0.8,<1.1.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
File diff suppressed because one or more lines are too long
+278
View File
@@ -5800,6 +5800,284 @@ def test_multiple_interrupts_functional_cache(
assert counter == 6
def test_task_before_interrupt_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly when a @task runs
before interrupt-producing tasks in an @entrypoint.
The @task wrapper on both setup and ask is essential to reproduce the bug:
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
- @task on ask means interrupt() runs in a child scratchpad that must
delegate to the parent for null resume consumption tracking
"""
@entrypoint(checkpointer=sync_checkpointer)
def workflow(number_of_topics: int) -> dict:
@task
def setup() -> int:
return number_of_topics
@task
def ask(question: str) -> str:
return interrupt(question)
n = setup().result()
answers = []
for i in range(n):
q = f"Whats the answer for topic {i + 1}?"
answers.append(ask(q).result())
return {"answers": answers}
config = {"configurable": {"thread_id": "1"}}
# First invocation - should get first interrupt
result = workflow.invoke(2, config=config)
assert "__interrupt__" in result
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
# Resume with answer for topic 1 - should get second interrupt
result = workflow.invoke(Command(resume="answer1"), config=config)
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
# Resume with answer for topic 2 - should get final result
result = workflow.invoke(Command(resume="answer2"), config=config)
assert result == {"answers": ["answer1", "answer2"]}
def test_multiple_tasks_before_interrupt_resume(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly when multiple @tasks
run before an interrupt-producing task in an @entrypoint."""
@entrypoint(checkpointer=sync_checkpointer)
def workflow(inputs: dict) -> dict:
@task
def step_a(x: int) -> int:
return x + 1
@task
def step_b(x: int) -> int:
return x * 2
@task
def ask(question: str) -> str:
return interrupt(question)
a = step_a(inputs["x"]).result()
b = step_b(a).result()
answer = ask(f"Result so far is {b}. What next?").result()
return {"computed": b, "answer": answer}
config = {"configurable": {"thread_id": "1"}}
# First invocation - should get interrupt
result = workflow.invoke({"x": 5}, config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
# Resume
result = workflow.invoke(Command(resume="continue"), config=config)
assert result == {"computed": 12, "answer": "continue"}
def test_no_redundant_put_writes_for_cached_task(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Cached @tasks on resume must not trigger redundant put_writes."""
from unittest.mock import patch
from langgraph.pregel._loop import PregelLoop
@task
def setup(x: int) -> int:
return x
@task
def ask(question: str) -> str:
return interrupt(question)
@entrypoint(checkpointer=sync_checkpointer)
def workflow(x: int) -> dict:
n = setup(x).result()
answer = ask(f"q{n}").result()
return {"answer": answer}
config = {"configurable": {"thread_id": "1"}}
result = workflow.invoke(1, config=config)
assert "__interrupt__" in result
put_writes_task_ids: list[str] = []
orig = PregelLoop.put_writes
def spy(self, task_id, writes):
put_writes_task_ids.append(task_id)
return orig(self, task_id, writes)
with patch.object(PregelLoop, "put_writes", spy):
result = workflow.invoke(Command(resume="ans"), config=config)
assert result == {"answer": "ans"}
# Count unique non-null task IDs that got put_writes.
# Should be exactly 2: the ask task and the entrypoint task.
# If 3, the cached setup task is being redundantly re-committed.
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
assert len(non_null) == 2, (
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
)
def test_node_before_interrupt_resume_graph_api(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly in a StateGraph when a
node runs before a node that calls interrupt(). This is the graph-API
analog of test_task_before_interrupt_resume (entrypoint API)."""
class State(TypedDict):
topics: list[str]
answers: Annotated[list[str], operator.add]
def setup(state: State) -> dict:
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
def ask(state: State) -> dict:
answers = []
for topic in state["topics"]:
answer = interrupt(f"Whats the answer for {topic}?")
answers.append(answer)
return {"answers": answers}
graph = (
StateGraph(State)
.add_node("setup", setup)
.add_node("ask", ask)
.add_edge(START, "setup")
.add_edge("setup", "ask")
.add_edge("ask", END)
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First invocation - setup runs, then ask interrupts on the first topic
result = graph.invoke({"topics": ["a", "b"], "answers": []}, config=config)
assert "__interrupt__" in result
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
# Resume with answer for topic 1 - should get second interrupt
result = graph.invoke(Command(resume="answer1"), config=config)
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
# Resume with answer for topic 2 - should complete
result = graph.invoke(Command(resume="answer2"), config=config)
assert result == {
"topics": ["topic 1", "topic 2"],
"answers": ["answer1", "answer2"],
}
def test_multiple_nodes_before_interrupt_resume_graph_api(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly in a StateGraph when
multiple nodes run before a node that calls interrupt(). This is the
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
class State(TypedDict):
value: int
answer: str
def step_a(state: State) -> dict:
return {"value": state["value"] + 1}
def step_b(state: State) -> dict:
return {"value": state["value"] * 2}
def ask(state: State) -> dict:
answer = interrupt(f"Result so far is {state['value']}. What next?")
return {"answer": answer}
graph = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("step_b", step_b)
.add_node("ask", ask)
.add_edge(START, "step_a")
.add_edge("step_a", "step_b")
.add_edge("step_b", "ask")
.add_edge("ask", END)
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First invocation - step_a and step_b run, then ask interrupts
result = graph.invoke({"value": 5, "answer": ""}, config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
# Resume - should complete
result = graph.invoke(Command(resume="continue"), config=config)
assert result == {"value": 12, "answer": "continue"}
def test_node_before_multiple_interrupt_cycles_graph_api(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that a node running before an interrupt node does not interfere
with multiple interrupt/resume cycles in a StateGraph."""
class State(TypedDict):
count: int
data: str
def prepare(state: State) -> dict:
return {"count": state["count"] + 10}
def multi_interrupt(state: State) -> dict:
first = interrupt("First question?")
second = interrupt("Second question?")
return {"data": f"{first},{second}"}
graph = (
StateGraph(State)
.add_node("prepare", prepare)
.add_node("multi_interrupt", multi_interrupt)
.add_edge(START, "prepare")
.add_edge("prepare", "multi_interrupt")
.add_edge("multi_interrupt", END)
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First invocation - prepare runs, multi_interrupt hits first interrupt
result = graph.invoke({"count": 0, "data": ""}, config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "First question?"
# Resume first interrupt - hits second interrupt
result = graph.invoke(Command(resume="first_answer"), config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "Second question?"
# Resume second interrupt - completes
result = graph.invoke(Command(resume="second_answer"), config=config)
assert result == {"count": 10, "data": "first_answer,second_answer"}
def test_double_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver) -> None:
class AgentState(TypedDict):
input: str
+284
View File
@@ -7920,6 +7920,290 @@ async def test_interrupts_in_tasks_surfaced_once(
assert result[1] == "Added Will!"
@NEEDS_CONTEXTVARS
async def test_task_before_interrupt_resume(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly when a @task runs
before interrupt-producing tasks in an @entrypoint.
The @task wrapper on both setup and ask is essential to reproduce the bug:
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
- @task on ask means interrupt() runs in a child scratchpad that must
delegate to the parent for null resume consumption tracking
"""
@entrypoint(checkpointer=async_checkpointer)
async def workflow(number_of_topics: int) -> dict:
@task
async def setup() -> int:
return number_of_topics
@task
async def ask(question: str) -> str:
return interrupt(question)
n = await setup()
answers = []
for i in range(n):
q = f"Whats the answer for topic {i + 1}?"
answers.append(await ask(q))
return {"answers": answers}
config = {"configurable": {"thread_id": "1"}}
# First invocation - should get first interrupt
result = await workflow.ainvoke(2, config=config)
assert "__interrupt__" in result
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
# Resume with answer for topic 1 - should get second interrupt
result = await workflow.ainvoke(Command(resume="answer1"), config=config)
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
# Resume with answer for topic 2 - should get final result
result = await workflow.ainvoke(Command(resume="answer2"), config=config)
assert result == {"answers": ["answer1", "answer2"]}
@NEEDS_CONTEXTVARS
async def test_multiple_tasks_before_interrupt_resume(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly when multiple @tasks
run before an interrupt-producing task in an @entrypoint."""
@entrypoint(checkpointer=async_checkpointer)
async def workflow(inputs: dict) -> dict:
@task
async def step_a(x: int) -> int:
return x + 1
@task
async def step_b(x: int) -> int:
return x * 2
@task
async def ask(question: str) -> str:
return interrupt(question)
a = await step_a(inputs["x"])
b = await step_b(a)
answer = await ask(f"Result so far is {b}. What next?")
return {"computed": b, "answer": answer}
config = {"configurable": {"thread_id": "1"}}
# First invocation - should get interrupt
result = await workflow.ainvoke({"x": 5}, config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
# Resume
result = await workflow.ainvoke(Command(resume="continue"), config=config)
assert result == {"computed": 12, "answer": "continue"}
@NEEDS_CONTEXTVARS
async def test_no_redundant_put_writes_for_cached_task(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Cached @tasks on resume must not trigger redundant put_writes."""
from unittest.mock import patch
from langgraph.pregel._loop import PregelLoop
@task
async def setup(x: int) -> int:
return x
@task
async def ask(question: str) -> str:
return interrupt(question)
@entrypoint(checkpointer=async_checkpointer)
async def workflow(x: int) -> dict:
n = await setup(x)
answer = await ask(f"q{n}")
return {"answer": answer}
config = {"configurable": {"thread_id": "1"}}
result = await workflow.ainvoke(1, config=config)
assert "__interrupt__" in result
put_writes_task_ids: list[str] = []
orig = PregelLoop.put_writes
def spy(self, task_id, writes):
put_writes_task_ids.append(task_id)
return orig(self, task_id, writes)
with patch.object(PregelLoop, "put_writes", spy):
result = await workflow.ainvoke(Command(resume="ans"), config=config)
assert result == {"answer": "ans"}
# Count unique non-null task IDs that got put_writes.
# Should be exactly 2: the ask task and the entrypoint task.
# If 3, the cached setup task is being redundantly re-committed.
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
assert len(non_null) == 2, (
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
)
@NEEDS_CONTEXTVARS
async def test_node_before_interrupt_resume_graph_api(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly in a StateGraph when a
node runs before a node that calls interrupt(). This is the graph-API
analog of test_task_before_interrupt_resume (entrypoint API)."""
class State(TypedDict):
topics: list[str]
answers: Annotated[list[str], operator.add]
def setup(state: State) -> dict:
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
def ask(state: State) -> dict:
answers = []
for topic in state["topics"]:
answer = interrupt(f"Whats the answer for {topic}?")
answers.append(answer)
return {"answers": answers}
graph = (
StateGraph(State)
.add_node("setup", setup)
.add_node("ask", ask)
.add_edge(START, "setup")
.add_edge("setup", "ask")
.add_edge("ask", END)
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First invocation - setup runs, then ask interrupts on the first topic
result = await graph.ainvoke({"topics": ["a", "b"], "answers": []}, config=config)
assert "__interrupt__" in result
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
# Resume with answer for topic 1 - should get second interrupt
result = await graph.ainvoke(Command(resume="answer1"), config=config)
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
assert len(result["__interrupt__"]) == 1
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
# Resume with answer for topic 2 - should complete
result = await graph.ainvoke(Command(resume="answer2"), config=config)
assert result == {
"topics": ["topic 1", "topic 2"],
"answers": ["answer1", "answer2"],
}
@NEEDS_CONTEXTVARS
async def test_multiple_nodes_before_interrupt_resume_graph_api(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that Command(resume=value) works correctly in a StateGraph when
multiple nodes run before a node that calls interrupt(). This is the
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
class State(TypedDict):
value: int
answer: str
def step_a(state: State) -> dict:
return {"value": state["value"] + 1}
def step_b(state: State) -> dict:
return {"value": state["value"] * 2}
def ask(state: State) -> dict:
answer = interrupt(f"Result so far is {state['value']}. What next?")
return {"answer": answer}
graph = (
StateGraph(State)
.add_node("step_a", step_a)
.add_node("step_b", step_b)
.add_node("ask", ask)
.add_edge(START, "step_a")
.add_edge("step_a", "step_b")
.add_edge("step_b", "ask")
.add_edge("ask", END)
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First invocation - step_a and step_b run, then ask interrupts
result = await graph.ainvoke({"value": 5, "answer": ""}, config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
# Resume - should complete
result = await graph.ainvoke(Command(resume="continue"), config=config)
assert result == {"value": 12, "answer": "continue"}
@NEEDS_CONTEXTVARS
async def test_node_before_multiple_interrupt_cycles_graph_api(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""Test that a node running before an interrupt node does not interfere
with multiple interrupt/resume cycles in a StateGraph."""
class State(TypedDict):
count: int
data: str
def prepare(state: State) -> dict:
return {"count": state["count"] + 10}
def multi_interrupt(state: State) -> dict:
first = interrupt("First question?")
second = interrupt("Second question?")
return {"data": f"{first},{second}"}
graph = (
StateGraph(State)
.add_node("prepare", prepare)
.add_node("multi_interrupt", multi_interrupt)
.add_edge(START, "prepare")
.add_edge("prepare", "multi_interrupt")
.add_edge("multi_interrupt", END)
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
# First invocation - prepare runs, multi_interrupt hits first interrupt
result = await graph.ainvoke({"count": 0, "data": ""}, config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "First question?"
# Resume first interrupt - hits second interrupt
result = await graph.ainvoke(Command(resume="first_answer"), config=config)
assert "__interrupt__" in result
assert result["__interrupt__"][0].value == "Second question?"
# Resume second interrupt - completes
result = await graph.ainvoke(Command(resume="second_answer"), config=config)
assert result == {"count": 10, "data": "first_answer,second_answer"}
async def test_pregel_loop_refcount():
gc.collect()
try:
+4 -4
View File
@@ -1367,7 +1367,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.8"
version = "1.0.9"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1607,7 +1607,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "orjson", specifier = ">=3.10.1" },
{ name = "orjson", specifier = ">=3.11.5" },
{ name = "psycopg", specifier = ">=3.2.0" },
{ name = "psycopg-pool", specifier = ">=3.2.0" },
]
@@ -1735,7 +1735,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.7"
version = "1.0.8"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -1812,7 +1812,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "orjson", specifier = ">=3.10.1" },
{ name = "orjson", specifier = ">=3.11.5" },
]
[package.metadata.requires-dev]
+11 -5
View File
@@ -922,7 +922,7 @@ class ToolNode(RunnableCallable):
raise TypeError(msg)
# Inject state, store, and runtime right before invocation
injected_call = self._inject_tool_args(call, request.runtime)
injected_call = self._inject_tool_args(call, request.runtime, tool)
call_args = {**injected_call, "type": "tool_call"}
try:
@@ -1075,7 +1075,7 @@ class ToolNode(RunnableCallable):
raise TypeError(msg)
# Inject state, store, and runtime right before invocation
injected_call = self._inject_tool_args(call, request.runtime)
injected_call = self._inject_tool_args(call, request.runtime, tool)
call_args = {**injected_call, "type": "tool_call"}
try:
@@ -1281,6 +1281,7 @@ class ToolNode(RunnableCallable):
self,
tool_call: ToolCall,
tool_runtime: ToolRuntime,
tool: BaseTool | None = None,
) -> ToolCall:
"""Inject graph state, store, and runtime into tool call arguments.
@@ -1299,6 +1300,9 @@ class ToolNode(RunnableCallable):
Must contain 'name', 'args', 'id', and 'type' fields.
tool_runtime: The ToolRuntime instance containing all runtime context
(state, config, store, context, stream_writer) to inject into tools.
tool: Optional tool instance. When provided, allows injection for
dynamically registered tools that are not in self.tools_by_name
(e.g., tools added via middleware's wrap_tool_call).
Returns:
A new ToolCall dictionary with the same structure as the input but with
@@ -1312,10 +1316,12 @@ class ToolNode(RunnableCallable):
This method is called automatically during tool execution. It should not
be called from outside the `ToolNode`.
"""
if tool_call["name"] not in self.tools_by_name:
return tool_call
injected = self._injected_args.get(tool_call["name"])
if not injected and tool is not None:
# For dynamically registered tools (e.g., added via middleware's
# wrap_tool_call), compute injected args on-the-fly since they
# were not present during ToolNode initialization.
injected = _get_all_injected_args(tool)
if not injected:
return tool_call
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "1.0.7"
version = "1.0.8"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.10"
+106
View File
@@ -1902,3 +1902,109 @@ async def test_tool_node_tool_runtime_generic() -> None:
assert tool_message.type == "tool"
assert tool_message.content == "test_info"
assert tool_message.tool_call_id == "call_1"
def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call() -> None:
"""Test that ToolRuntime is injected for dynamically registered tools.
Regression test for https://github.com/langchain-ai/langchain/issues/35305.
When a tool is dynamically provided via wrap_tool_call (not registered at
ToolNode init time), ToolRuntime should still be injected into the tool.
"""
@dec_tool
def static_tool(x: int) -> str:
"""A static tool registered at init."""
return f"static: {x}"
@dec_tool
def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
"""A dynamic tool that needs ToolRuntime injection."""
return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"
def wrap_tool_call(request, execute):
"""Middleware that swaps in a dynamic tool."""
if request.tool_call["name"] == "dynamic_tool_with_runtime":
# Override tool to the dynamic one (not registered at init)
new_request = request.override(tool=dynamic_tool_with_runtime)
return execute(new_request)
return execute(request)
# ToolNode only knows about static_tool at init time
tool_node = ToolNode(
[static_tool],
wrap_tool_call=wrap_tool_call,
)
# Verify the dynamic tool is NOT in the tool node's registered tools
assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name
# Call the dynamic tool
tool_call = {
"name": "dynamic_tool_with_runtime",
"args": {"x": 42},
"id": "call_dynamic_1",
"type": "tool_call",
}
msg = AIMessage("", tool_calls=[tool_call])
result = tool_node.invoke(
{"messages": [msg]},
config=_create_config_with_runtime(),
)
# ToolRuntime should be injected and the tool should execute successfully
tool_message = result["messages"][-1]
assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_1"
assert tool_message.tool_call_id == "call_dynamic_1"
async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async() -> None:
"""Test that ToolRuntime is injected for dynamically registered tools (async).
Async version of the regression test for
https://github.com/langchain-ai/langchain/issues/35305.
"""
@dec_tool
def static_tool(x: int) -> str:
"""A static tool registered at init."""
return f"static: {x}"
@dec_tool
async def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
"""A dynamic async tool that needs ToolRuntime injection."""
return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"
async def awrap_tool_call(request, execute):
"""Async middleware that swaps in a dynamic tool."""
if request.tool_call["name"] == "dynamic_tool_with_runtime":
new_request = request.override(tool=dynamic_tool_with_runtime)
return await execute(new_request)
return await execute(request)
# ToolNode only knows about static_tool at init time
tool_node = ToolNode(
[static_tool],
awrap_tool_call=awrap_tool_call,
)
# Verify the dynamic tool is NOT in the tool node's registered tools
assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name
# Call the dynamic tool
tool_call = {
"name": "dynamic_tool_with_runtime",
"args": {"x": 42},
"id": "call_dynamic_2",
"type": "tool_call",
}
msg = AIMessage("", tool_calls=[tool_call])
result = await tool_node.ainvoke(
{"messages": [msg]},
config=_create_config_with_runtime(),
)
# ToolRuntime should be injected and the tool should execute successfully
tool_message = result["messages"][-1]
assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_2"
assert tool_message.tool_call_id == "call_dynamic_2"
+7 -7
View File
@@ -249,7 +249,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.12"
version = "1.2.13"
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/2a/1d/08e935d1532fcc90981f6e5bb6825914c9227ea7a962c62b1e18619b49e7/langchain_core-1.2.12.tar.gz", hash = "sha256:4d7fa6643d7ab06fc1905a9b7dcbe96a6f3c181046b56edf9c0c17ecd412d9e9", size = 831329, upload-time = "2026-02-12T20:53:15.01Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/a5/678ab0e5cc57794f20ae5ed12c1442506ef1108c9434f950aebc6044e5a3/langchain_core-1.2.12-py3-none-any.whl", hash = "sha256:66ca17a2a9cb007ab29021968e6adfcf4228067151dc2bd6ebfff265ffaf92f5", size = 500132, upload-time = "2026-02-12T20:53:13.806Z" },
{ url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
]
[[package]]
name = "langgraph"
version = "1.0.8"
version = "1.0.9"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -411,7 +411,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "orjson", specifier = ">=3.10.1" },
{ name = "orjson", specifier = ">=3.11.5" },
{ name = "psycopg", specifier = ">=3.2.0" },
{ name = "psycopg-pool", specifier = ">=3.2.0" },
]
@@ -489,7 +489,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.7"
version = "1.0.8"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -585,7 +585,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "orjson", specifier = ">=3.10.1" },
{ name = "orjson", specifier = ">=3.11.5" },
]
[package.metadata.requires-dev]
+1 -1
View File
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import EncryptionContext
__version__ = "0.3.6"
__version__ = "0.3.8"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
@@ -290,7 +290,7 @@ class AssistantsClient:
"""
get_params = {"recurse": recurse}
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
if namespace is not None:
return await self.http.get(
f"/assistants/{assistant_id}/subgraphs/{namespace}",
@@ -425,9 +425,9 @@ class AssistantsClient:
payload: dict[str, Any] = {}
if graph_id:
payload["graph_id"] = graph_id
if config:
if config is not None:
payload["config"] = config
if context:
if context is not None:
payload["context"] = context
if metadata:
payload["metadata"] = metadata
+2 -2
View File
@@ -110,7 +110,7 @@ def get_client(
if url is None:
url = "http://api"
if os.environ.get("__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT") == "true":
transport = get_asgi_transport()(app=None, root_path="/noauth")
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
_registered_transports.append(transport)
else:
try:
@@ -122,7 +122,7 @@ def get_client(
"Failed to connect to in-process LangGraph server. Deferring configuration.",
exc_info=True,
)
transport = get_asgi_transport()(app=None, root_path="/noauth")
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
_registered_transports.append(transport)
if transport is None:
+64 -5
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from collections.abc import Mapping
import warnings
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any
@@ -14,11 +15,13 @@ from langgraph_sdk.schema import (
Cron,
CronSelectField,
CronSortBy,
Durability,
Input,
OnCompletionBehavior,
QueryParamTypes,
Run,
SortOrder,
StreamMode,
)
@@ -60,13 +63,17 @@ class CronClient:
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
@@ -83,7 +90,7 @@ class CronClient:
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
@@ -93,6 +100,13 @@ class CronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled or not.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -118,6 +132,13 @@ class CronClient:
)
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
@@ -131,6 +152,10 @@ class CronClient:
"webhook": webhook,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
if multitask_strategy:
payload["multitask_strategy"] = multitask_strategy
@@ -151,7 +176,7 @@ class CronClient:
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
@@ -159,6 +184,10 @@ class CronClient:
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
@@ -174,7 +203,7 @@ class CronClient:
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
@@ -186,6 +215,13 @@ class CronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled or not.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -211,6 +247,13 @@ class CronClient:
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
@@ -225,6 +268,10 @@ class CronClient:
"on_run_completed": on_run_completed,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
if multitask_strategy:
payload["multitask_strategy"] = multitask_strategy
@@ -277,6 +324,10 @@ class CronClient:
interrupt_after: All | list[str] | None = None,
on_run_completed: OnCompletionBehavior | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Cron:
@@ -299,6 +350,10 @@ class CronClient:
after execution. 'keep' creates a new thread for each execution but does not
clean them up.
enabled: Enable or disable the cron job.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -329,6 +384,10 @@ class CronClient:
"interrupt_after": interrupt_after,
"on_run_completed": on_run_completed,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.patch(
+8 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import builtins
import warnings
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from typing import Any, overload
@@ -507,11 +508,11 @@ class RunsClient:
async def create_batch(
self,
payloads: list[RunCreate],
payloads: builtins.list[RunCreate],
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Run]:
) -> builtins.list[Run]:
"""Create a batch of stateless background runs."""
def filter_payload(payload: RunCreate):
@@ -547,7 +548,7 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
) -> list[dict] | dict[str, Any]: ...
) -> builtins.list[dict] | dict[str, Any]: ...
@overload
async def wait(
@@ -572,7 +573,7 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
) -> list[dict] | dict[str, Any]: ...
) -> builtins.list[dict] | dict[str, Any]: ...
async def wait(
self,
@@ -600,7 +601,7 @@ class RunsClient:
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> list[dict] | dict[str, Any]:
) -> builtins.list[dict] | dict[str, Any]:
"""Create a run, wait until it finishes and return the final state.
Args:
@@ -751,10 +752,10 @@ class RunsClient:
limit: int = 10,
offset: int = 0,
status: RunStatus | None = None,
select: list[RunSelectField] | None = None,
select: builtins.list[RunSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Run]:
) -> builtins.list[Run]:
"""List runs.
Args:
+1 -1
View File
@@ -138,7 +138,7 @@ class StoreClient:
if refresh_ttl is not None:
get_params["refresh_ttl"] = refresh_ttl
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
return await self.http.get("/store/items", params=get_params, headers=headers)
async def delete_item(
+2 -2
View File
@@ -543,7 +543,7 @@ class ThreadsClient:
elif checkpoint_id:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{thread_id}/state/{checkpoint_id}",
params=get_params,
@@ -552,7 +552,7 @@ class ThreadsClient:
else:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{thread_id}/state",
params=get_params,
@@ -294,7 +294,7 @@ class SyncAssistantsClient:
"""
get_params = {"recurse": recurse}
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
if namespace is not None:
return self.http.get(
f"/assistants/{assistant_id}/subgraphs/{namespace}",
@@ -427,9 +427,9 @@ class SyncAssistantsClient:
payload: dict[str, Any] = {}
if graph_id:
payload["graph_id"] = graph_id
if config:
if config is not None:
payload["config"] = config
if context:
if context is not None:
payload["context"] = context
if metadata:
payload["metadata"] = metadata
+64 -5
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from collections.abc import Mapping
import warnings
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any
@@ -14,11 +15,13 @@ from langgraph_sdk.schema import (
Cron,
CronSelectField,
CronSortBy,
Durability,
Input,
OnCompletionBehavior,
QueryParamTypes,
Run,
SortOrder,
StreamMode,
)
@@ -54,13 +57,17 @@ class SyncCronClient:
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
@@ -77,7 +84,7 @@ class SyncCronClient:
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
@@ -85,6 +92,13 @@ class SyncCronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled. By default, it is considered enabled.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
Returns:
@@ -109,6 +123,13 @@ class SyncCronClient:
)
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
@@ -123,6 +144,10 @@ class SyncCronClient:
"multitask_strategy": multitask_strategy,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
@@ -141,7 +166,7 @@ class SyncCronClient:
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
checkpoint_during: bool | None = None, # deprecated
interrupt_before: All | list[str] | None = None,
interrupt_after: All | list[str] | None = None,
webhook: str | None = None,
@@ -149,6 +174,10 @@ class SyncCronClient:
multitask_strategy: str | None = None,
end_time: datetime | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Run:
@@ -164,7 +193,7 @@ class SyncCronClient:
config: The configuration for the assistant.
context: Static context to add to the assistant.
!!! version-added "Added in version 0.6.0"
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
interrupt_before: Nodes to interrupt immediately before they get executed.
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
webhook: Webhook to call after LangGraph API call is done.
@@ -176,6 +205,13 @@ class SyncCronClient:
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
enabled: Whether the cron job is enabled. By default, it is considered enabled.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
headers: Optional custom headers to include with the request.
Returns:
@@ -201,6 +237,13 @@ class SyncCronClient:
```
"""
if checkpoint_during is not None:
warnings.warn(
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
DeprecationWarning,
stacklevel=2,
)
payload = {
"schedule": schedule,
"input": input,
@@ -216,6 +259,10 @@ class SyncCronClient:
"multitask_strategy": multitask_strategy,
"end_time": end_time.isoformat() if end_time else None,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
@@ -266,6 +313,10 @@ class SyncCronClient:
interrupt_after: All | list[str] | None = None,
on_run_completed: OnCompletionBehavior | None = None,
enabled: bool | None = None,
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
stream_subgraphs: bool | None = None,
stream_resumable: bool | None = None,
durability: Durability | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> Cron:
@@ -288,6 +339,10 @@ class SyncCronClient:
after execution. 'keep' creates a new thread for each execution but does not
clean them up.
enabled: Enable or disable the cron job.
stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs.
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
headers: Optional custom headers to include with the request.
params: Optional query parameters to include with the request.
@@ -318,6 +373,10 @@ class SyncCronClient:
"interrupt_after": interrupt_after,
"on_run_completed": on_run_completed,
"enabled": enabled,
"stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs,
"stream_resumable": stream_resumable,
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.patch(
+8 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import builtins
import warnings
from collections.abc import Callable, Iterator, Mapping, Sequence
from typing import Any, overload
@@ -503,11 +504,11 @@ class SyncRunsClient:
def create_batch(
self,
payloads: list[RunCreate],
payloads: builtins.list[RunCreate],
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Run]:
) -> builtins.list[Run]:
"""Create a batch of stateless background runs."""
def filter_payload(payload: RunCreate):
@@ -543,7 +544,7 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
) -> list[dict] | dict[str, Any]: ...
) -> builtins.list[dict] | dict[str, Any]: ...
@overload
def wait(
@@ -568,7 +569,7 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
) -> list[dict] | dict[str, Any]: ...
) -> builtins.list[dict] | dict[str, Any]: ...
def wait(
self,
@@ -596,7 +597,7 @@ class SyncRunsClient:
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> list[dict] | dict[str, Any]:
) -> builtins.list[dict] | dict[str, Any]:
"""Create a run, wait until it finishes and return the final state.
Args:
@@ -740,10 +741,10 @@ class SyncRunsClient:
limit: int = 10,
offset: int = 0,
status: RunStatus | None = None,
select: list[RunSelectField] | None = None,
select: builtins.list[RunSelectField] | None = None,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> list[Run]:
) -> builtins.list[Run]:
"""List runs.
Args:
+2 -2
View File
@@ -530,7 +530,7 @@ class SyncThreadsClient:
elif checkpoint_id:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{thread_id}/state/{checkpoint_id}",
params=get_params,
@@ -539,7 +539,7 @@ class SyncThreadsClient:
else:
get_params = {"subgraphs": subgraphs}
if params:
get_params = {**get_params, **params}
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{thread_id}/state",
params=get_params,
+106 -6
View File
@@ -72,8 +72,13 @@ class Auth:
assert params.get("metadata", {}).get("owner") == "allowed_user"
@auth.on.store
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on):
assert ctx.user.identity in value["namespace"], "Not authorized"
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on.store.value):
# Automatically scope all store operations to the user's namespace.
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
assert isinstance(namespace, tuple)
if not namespace or namespace[0] != ctx.user.identity:
namespace = (ctx.user.identity, *namespace)
value["namespace"] = namespace
```
???+ note "Request Processing Flow"
@@ -170,13 +175,32 @@ class Auth:
```
Auth for the `store` resource is a bit different since its structure is developer defined.
You typically want to enforce user creds in the namespace.
You typically want to scope store operations by rewriting the namespace to include the user's identity.
The `value` dict is mutable — changes to `value["namespace"]` are used by the server for the actual operation.
```python
@auth.on.store
async def check_store_access(ctx: AuthContext, value: Auth.types.on) -> bool:
# Assuming you structure your store like (store.aput((user_id, application_context), key, value))
assert value["namespace"][0] == ctx.user.identity
async def authorize_store(ctx: AuthContext, value: Auth.types.on.store.value):
# Automatically scope all store operations to the user's namespace.
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
assert isinstance(namespace, tuple)
if not namespace or namespace[0] != ctx.user.identity:
namespace = (ctx.user.identity, *namespace)
value["namespace"] = namespace
```
You can also register handlers for specific store actions:
```python
@auth.on.store.put
async def on_put(ctx: AuthContext, value: Auth.types.on.store.put.value):
# value has typed fields: namespace, key, value, index
...
@auth.on.store.get
async def on_get(ctx: AuthContext, value: Auth.types.on.store.get.value):
# value has typed fields: namespace, key
...
```
"""
# These are accessed by the API. Changes to their names or types is
@@ -483,9 +507,85 @@ class _CronsOn(
Search = types.CronsSearch
class _StoreActionOn(typing.Generic[T]):
"""Decorator for registering a handler for a specific store action."""
def __init__(
self,
auth: Auth,
action: typing.Literal["put", "get", "search", "delete", "list_namespaces"],
value: type[T],
) -> None:
self.auth = auth
self.action = action
self.value = value
def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]:
_validate_handler(fn)
_register_handler(self.auth, "store", self.action, fn)
return fn
class _StoreOn:
def __init__(self, auth: Auth) -> None:
self._auth = auth
self.put = _StoreActionOn(auth, "put", types.StorePut)
"""Register a handler for store put operations.
???+ example "Example"
```python
@auth.on.store.put
async def on_store_put(ctx: Auth.types.AuthContext, value: Auth.types.on.store.put.value):
# Scope puts to user's namespace
...
```
"""
self.get = _StoreActionOn(auth, "get", types.StoreGet)
"""Register a handler for store get operations.
???+ example "Example"
```python
@auth.on.store.get
async def on_store_get(ctx: Auth.types.AuthContext, value: Auth.types.on.store.get.value):
# Scope gets to user's namespace
...
```
"""
self.search = _StoreActionOn(auth, "search", types.StoreSearch)
"""Register a handler for store search operations.
???+ example "Example"
```python
@auth.on.store.search
async def on_store_search(ctx: Auth.types.AuthContext, value: Auth.types.on.store.search.value):
# Scope searches to user's namespace
...
```
"""
self.delete = _StoreActionOn(auth, "delete", types.StoreDelete)
"""Register a handler for store delete operations.
???+ example "Example"
```python
@auth.on.store.delete
async def on_store_delete(ctx: Auth.types.AuthContext, value: Auth.types.on.store.delete.value):
# Scope deletes to user's namespace
...
```
"""
self.list_namespaces = _StoreActionOn(
auth, "list_namespaces", types.StoreListNamespaces
)
"""Register a handler for store list_namespaces operations.
???+ example "Example"
```python
@auth.on.store.list_namespaces
async def on_list_ns(ctx: Auth.types.AuthContext, value: Auth.types.on.store.list_namespaces.value):
# Scope namespace listing to user's prefix
...
```
"""
@typing.overload
def __call__(
+52 -14
View File
@@ -402,7 +402,7 @@ class AuthContext(BaseAuthContext):
"list_namespaces",
]
"""The action being performed on the resource.
Most resources support the following actions:
- create: Create a new resource
- read: Read information about a resource
@@ -411,8 +411,10 @@ class AuthContext(BaseAuthContext):
- search: Search for resources
The store supports the following actions:
- put: Add or update a document in the store
- get: Get a document from the store
- put: Add or update an item in the store
- get: Get an item from the store
- search: Search for items within a namespace prefix
- delete: Delete an item from the store
- list_namespaces: List the namespaces in the store
"""
@@ -851,20 +853,34 @@ class CronsSearch(typing.TypedDict, total=False):
class StoreGet(typing.TypedDict):
"""Operation to retrieve a specific item by its namespace and key."""
"""Operation to retrieve a specific item by its namespace and key.
This dict is mutable — auth handlers can modify `namespace` to enforce
access scoping (e.g., prepending the user's identity).
"""
namespace: tuple[str, ...]
"""Hierarchical path that uniquely identifies the item's location."""
"""Hierarchical path that uniquely identifies the item's location.
Auth handlers can modify this to enforce per-user scoping.
"""
key: str
"""Unique identifier for the item within its specific namespace."""
class StoreSearch(typing.TypedDict):
"""Operation to search for items within a specified namespace hierarchy."""
"""Operation to search for items within a specified namespace hierarchy.
This dict is mutable — auth handlers can modify `namespace` to enforce
access scoping (e.g., prepending the user's identity).
"""
namespace: tuple[str, ...]
"""Prefix filter for defining the search scope."""
"""Prefix filter for defining the search scope.
Auth handlers can modify this to enforce per-user scoping.
"""
filter: dict[str, typing.Any] | None
"""Key-value pairs for filtering results based on exact matches or comparison operators."""
@@ -876,14 +892,22 @@ class StoreSearch(typing.TypedDict):
"""Number of matching items to skip for pagination."""
query: str | None
"""Naturalj language search query for semantic search capabilities."""
"""Natural language search query for semantic search capabilities."""
class StoreListNamespaces(typing.TypedDict):
"""Operation to list and filter namespaces in the store."""
"""Operation to list and filter namespaces in the store.
This dict is mutable — auth handlers can modify `namespace` (the prefix)
to enforce access scoping (e.g., prepending the user's identity).
"""
namespace: tuple[str, ...] | None
"""Prefix filter namespaces."""
"""Prefix filter for namespaces. Can be `None` if no prefix was provided.
Auth handlers can modify this to enforce per-user scoping. When `None`,
handlers should set it to `(user_id,)` to scope listing to the user's namespaces.
"""
suffix: tuple[str, ...] | None
"""Optional conditions for filtering namespaces."""
@@ -903,10 +927,17 @@ class StoreListNamespaces(typing.TypedDict):
class StorePut(typing.TypedDict):
"""Operation to store, update, or delete an item in the store."""
"""Operation to store, update, or delete an item in the store.
This dict is mutable — auth handlers can modify `namespace` to enforce
access scoping (e.g., prepending the user's identity).
"""
namespace: tuple[str, ...]
"""Hierarchical path that identifies the location of the item."""
"""Hierarchical path that identifies the location of the item.
Auth handlers can modify this to enforce per-user scoping.
"""
key: str
"""Unique identifier for the item within its namespace."""
@@ -919,10 +950,17 @@ class StorePut(typing.TypedDict):
class StoreDelete(typing.TypedDict):
"""Operation to delete an item from the store."""
"""Operation to delete an item from the store.
This dict is mutable — auth handlers can modify `namespace` to enforce
access scoping (e.g., prepending the user's identity).
"""
namespace: tuple[str, ...]
"""Hierarchical path that uniquely identifies the item's location."""
"""Hierarchical path that uniquely identifies the item's location.
Auth handlers can modify this to enforce per-user scoping.
"""
key: str
"""Unique identifier for the item within its specific namespace."""
+11 -1
View File
@@ -146,7 +146,9 @@ AssistantSortBy = Literal[
The field to sort by.
"""
ThreadSortBy = Literal["thread_id", "status", "created_at", "updated_at"]
ThreadSortBy = Literal[
"thread_id", "status", "created_at", "updated_at", "state_updated_at"
]
"""
The field to sort by.
"""
@@ -422,6 +424,14 @@ class CronUpdate(TypedDict, total=False):
"""What to do with the thread after the run completes."""
enabled: bool
"""Enable or disable the cron job."""
stream_mode: StreamMode | list[StreamMode]
"""The stream mode(s) to use."""
stream_subgraphs: bool
"""Whether to stream output from subgraphs."""
stream_resumable: bool
"""Whether to persist the stream chunks in order to resume the stream later."""
durability: Durability
"""Durability level for the run. Must be one of 'sync', 'async', or 'exit'."""
# Select field aliases for client-side typing of `select` parameters.
+1 -1
View File
@@ -11,7 +11,7 @@ requires-python = ">=3.10"
readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = ["httpx>=0.25.2", "orjson>=3.10.1"]
dependencies = ["httpx>=0.25.2", "orjson>=3.11.5"]
[tool.hatch.version]
path = "langgraph_sdk/__init__.py"
+2 -2
View File
@@ -19,7 +19,7 @@ class AsyncListByteStream(httpx.AsyncByteStream):
self._chunks = list(chunks)
self._exc = exc
async def __aiter__(self): # type: ignore[override]
async def __aiter__(self):
for chunk in self._chunks:
yield chunk
if self._exc is not None:
@@ -34,7 +34,7 @@ class ListByteStream(httpx.ByteStream):
self._chunks = list(chunks)
self._exc = exc
def __iter__(self): # type: ignore[override]
def __iter__(self):
yield from self._chunks
if self._exc is not None:
raise self._exc
+2 -2
View File
@@ -65,7 +65,7 @@ def test_raise_for_status_typed_maps_exceptions_and_sets_status_code(
with pytest.raises(exc_type) as ei:
_raise_for_status_typed(r)
err = cast("APIStatusError", ei.value)
err = ei.value
assert err.status_code == status
# response attribute should be present and match
assert err.response.status_code == status
@@ -113,7 +113,7 @@ def test_error_message_in_str_and_args() -> None:
r = make_response(422, json_body={"message": "Validation failed"})
with pytest.raises(UnprocessableEntityError) as ei:
_raise_for_status_typed(r)
err = cast("UnprocessableEntityError", ei.value)
err = ei.value
assert str(err) == "Validation failed"
assert err.args == ("Validation failed",)
assert err.message == "Validation failed"
+3 -3
View File
@@ -265,7 +265,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.0.8"
version = "1.0.9"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -396,7 +396,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "1.0.7"
version = "1.0.8"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -484,7 +484,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "orjson", specifier = ">=3.10.1" },
{ name = "orjson", specifier = ">=3.11.5" },
]
[package.metadata.requires-dev]