Compare commits

..
Author SHA1 Message Date
Hari Dhanushkodiandopen-swe[bot] 6e144fb893 feat(cli): log in to LangSmith without an API key
Reuse the `langsmith login` OAuth profile (~/.langsmith/config.json) for the
deploy commands, refreshing the 5-minute access token per request, and add
`langgraph login` (device flow). With no credentials, an interactive deploy
starts the login flow instead of prompting for an API key.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-30 20:16:51 +00:00
Elior Nataf Lackritz 1f0cf816d5 fix(langgraph): give messages and tools events the namespace of their graph (#9458)
Fixes #6373
Fixes #5674

`messages` and `tools` events should stream under the namespace of the
graph they ran in, and a stream without `subgraphs=True` should get only
its own graph's events, plus those of a graph that one of its nodes
streams itself. langchain-ai/langgraphjs#3121 applies these rules in JS.
Python broke them in four places:

- **`@task` calls.** A task call nests its checkpoint namespace segment
under its caller's, although it runs in the caller's graph. The handlers
dropped only the last segment, so a model or tool called in a task
looked like a subgraph's: without `subgraphs=True` its events were
dropped, so a functional API agent streamed no tokens (#6373), and with
it they came under the calling node's namespace, one level deeper per
nested call. The handlers now also drop one segment per enclosing task
call, read from the task's `langgraph_path`. Send tasks and plain nodes
are unchanged.
- **A graph that runs outside the caller's task.** A graph invoked
inside a node with its own `thread_id`, `checkpoint_ns` or
`checkpoint_id`, or through a LangChain runnable called with its own
`configurable` (which replaces the caller's), starts its namespace at
the root, so a handler inherited from the caller streamed its events as
the caller's own, even with `subgraphs=False` (#5674). Such a graph now
drops the `messages` and `tools` handlers it inherits, whatever their
`subgraphs`, so its events stay out of the caller's stream like its
`updates` and `custom` output. A node that streams it still gets its
events.
- **A graph a node streams after running another.** The loop numbers the
namespace of every subgraph a task runs after its first (`node:<id>|1`),
but the handlers read their own namespace before that, so the node got
none of that graph's events. They now read it from the loop.
- **`push_message`.** It kept the pushing node's own segment, ignored
`subgraphs=False`, and reached only the first messages handler, so a
node that streamed a graph never got that graph's pushed messages. It
now follows the same rules, for every messages handler.

Reading the namespace from the loop changes one more case: a root run
given a subgraph's config, with its `checkpoint_ns` (as
`get_state(subgraphs=True)` returns it), took that namespace as its own
and streamed the subgraph's tokens with `subgraphs=False`. The loop runs
it at the root, so it now streams the subgraph node's returned messages,
like any root run.

`stream_events(version="v3")` reads the same namespaces, so a task's
tokens and tool calls and a root node's pushed messages now show in the
run's `messages` and `tool_calls` projections, where they were missing.

The new tests in `test_stream_namespaces.py` fail on `main`, except two
guards that invoke a child graph with no config. No existing test
changed.

Thanks @sdole and @Kannndev for the reports, @lxingy3 for the smaller
repro and diagnosis on #5674, and @roydonsequeira for the review that
caught the `subgraphs=True` case and the 3.10 failure.

## Release note

`messages` and `tools` events from a `@task` now stream without
`subgraphs=True`, as events of the graph that called the task, so a
functional API agent's `messages` stream carries its tokens. With
`subgraphs=True` they move from the calling node's namespace, such as
`("workflow:<id>",)`, to their graph's, `()` at the root. A graph
invoked inside a node with its own `thread_id`, `checkpoint_ns` or
`checkpoint_id`, or through a runnable called with its own
`configurable`, no longer streams into the caller's `messages` or
`tools` stream, even when the caller passes `subgraphs=True`; a node
that streams it still gets its events. A node that streams a graph after
running another one now gets that graph's events. Messages sent with
`push_message` stream under the namespace of their graph, stay out of a
stream without `subgraphs=True` when they come from a subgraph, and
reach a node that streams their graph. A root run given a subgraph's
`checkpoint_ns` with `subgraphs=False` now streams that subgraph node's
returned messages instead of its tokens. The `stream_mode` chunks that
`astream_events` carries change the same way; its own events do not.
2026-10-30 15:07:34 -04:00
Elior Nataf Lackritz 6f2df053f4 test(sdk-py): check that a reconnect loop resumes after the newest event (#9483)
Follow-up to the review on #9482: with `stream_resumable=True`, does a
loop of reconnects replay events? Each reconnect sends the newest event
id the stream got, and a failed reconnect keeps that id for the next
one, so the server replays only what the client missed. The existing
loop tests served the same events whatever the cursor, so nothing
checked this.

`test_*_reconnect_loop_resumes_after_the_newest_event` checks the
`Last-Event-ID` of every request in two loops, sync and async: progress
between drops, and a 502 between two reconnects. A stream that kept its
first cursor, or dropped it after a failed reconnect, fails it.
2026-10-29 15:08:20 -04:00
Elior Nataf Lackritz ffc3336367 fix(sdk-py): retry runs.wait, join and cancel(wait=True) reconnects with backoff (#9481)
`runs.wait`, `runs.join` and `runs.cancel(wait=True)` reconnect to the
run's `Location` when the response is cut off, but with no delay: a 429,
a 5xx or a refused connection on the reconnect raised at once, and a
dropped body reconnected again immediately, so they could not ride out a
server restart or a deploy. #9462 fixed the same for streams.

The reconnect now waits with the stream path's backoff and retries those
failures within the same five attempts; any other status and a failed
first request still raise. Each attempt is one GET with
`reconnect_limit=0`, so the loop owns every retry (the join `Location`
points back at itself, so nothing is lost by not following it).
`request_reconnect` raises plain `httpx.HTTPStatusError`, so it checks
429 and 5xx directly, the same set the stream path catches as
`RateLimitError` and `InternalServerError`.

`test_wait_retries_a_failed_reconnect_after_a_backoff` covers each
failure, sync and async, and fails on `main`; the other two new tests
check the budget and that a 404 still raises. `no_sleep` moves to
`conftest.py` so both test files can use it.
2026-10-28 18:55:31 -04:00
Elior Nataf Lackritz 5f4b28a9f7 fix(sdk-py): warn when a stream reconnects without a Last-Event-ID (#9482)
A dropped stream reconnects even when it has no event id to resume from
(a run created without `stream_resumable=True`, or one that dropped
before its first event). The reconnect can't replay what was sent while
it was disconnected, so those events were lost without a sign.

The SDK now warns when a reconnect request carries no `Last-Event-ID`,
whether the cursor would come from the stream's ids or from the caller's
own header. It still reconnects: everything sent after the reconnect
arrives, so refusing would turn a gap into losing the rest of the
stream.

The new table test covers a stream with no ids (warns; fails on `main`),
one with ids and one with a caller cursor (no warning), sync and async.
The empty-id tests now expect the warning, since they reconnect without
a cursor too.
2026-10-28 18:44:56 -04:00
dependabot[bot] 5c96bce8d3 chore(deps): bump setuptools from 83.0.0 to 84.1.0 in /libs/langgraph (#9433)
Bumps [setuptools](https://github.com/pypa/setuptools) from 83.0.0 to
84.1.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pypa/setuptools/blob/main/NEWS.rst">setuptools's
changelog</a>.</em></p>
<blockquote>
<h1>v84.1.0</h1>
<h2>Features</h2>
<ul>
<li><code>setuptools.archive_util</code> now raises the new
<code>UnsafeMember</code> exception when
an archive member would be extracted outside of the extraction
directory,
where previously such a member was silently skipped. Aborting makes a
malicious or malformed archive visible to the caller instead of yielding
a
quietly incomplete extraction, and matches the behavior of the standard
library's <code>tarfile</code> extraction filters.</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>
<p><code>Wheel.install_as_egg</code> no longer creates namespace package
directories
outside of the destination egg directory. Entries in a wheel's
<code>namespace_packages.txt</code> were split on <code>.</code> and
passed straight to
<code>os.path.join</code>, so an entry that was absolute,
drive-qualified, or UNC
discarded the destination and placed an <code>__init__.py</code> at a
path chosen by
the wheel. Namespace entries are now validated as dotted module names on
every platform -- see GHSA-xmj3-9gf7-h52w.</p>
<p>This code path is reached only by the deprecated
<code>setup_requires</code>
mechanism when <code>setup()</code> is invoked outside a PEP 517
frontend; the
setuptools build backend never reaches it.</p>
</li>
<li>
<p><code>setuptools.archive_util</code> no longer extracts archive
members outside of the
requested extraction directory. Both the tar and zip formats specify
<code>/</code> as
the only path separator, but the guard against <code>..</code>
components split member
names on <code>/</code> alone, so a name such as
<code>..\escaped.txt</code> was treated as a
single component and then resolved as a traversal by the filesystem on
Windows. Member names containing a backslash, a drive letter, or a UNC
prefix
are now rejected on every platform, as are members whose resolved
destination
falls outside the extraction directory -- see GHSA-grgh-hr87-3jpw.</p>
</li>
</ul>
<h1>v84.0.0</h1>
<h2>Features</h2>
<ul>
<li>Newline-separated <code>keywords</code> and
<code>platforms</code><code>pypa/setuptools#4887</code><code>old
specification &lt;https://peps.python.org/pep-0345/&gt;</code>_
separated items with spaces and the current one uses commas. (<a
href="https://redirect.github.com/pypa/setuptools/issues/4887">#4887</a>)</li>
<li><code>Extension</code><code>pypa/distutils#373</code><a
href="https://redirect.github.com/pypa/setuptools/issues/5022">#5022</a>)</li>
<li>The C compiler modules now emit log messages through their own
<code>compilers.C.*</code> loggers instead of the distutils root logger,
part of decoupling the compilers package from distutils. The logger
names are normalized to a stable <code>compilers.C.*</code> prefix so
they remain constant as the package migrates toward a standalone
<code>compilers.C</code> distribution. (<a
href="https://redirect.github.com/pypa/setuptools/issues/5266">#5266</a>)</li>
<li>The C compilers gained a <code>Compiler.call</code> method -- a thin
wrapper over <code>subprocess.check_call</code> (with macOS
deployment-target env injection) that is the modern replacement for
<code>Compiler.spawn</code>. The compilers no longer depend on
<code>distutils.spawn</code>, <code>distutils.dir_util</code>,
<code>distutils.file_util</code>, <code>distutils._modified</code>, or
<code>distutils.util.execute</code>/<code>split_quoted</code>: the
generic <code>newer</code>/<code>newer_group</code> and
<code>split_quoted</code> helpers are vendored into the
<code>compilers</code> package, and
<code>Compiler.mkpath</code>/<code>move_file</code>/<code>execute</code>
are implemented directly on the standard library
(<code>os.makedirs</code>/<code>shutil.move</code>). The methods are
retained for backward compatibility. (<a
href="https://redirect.github.com/pypa/setuptools/issues/5267">#5267</a>)</li>
<li>The compilers no longer depend on <code>distutils.util</code>,
<code>distutils.version</code>, <code>distutils.compat</code>, or
<code>distutils._macos_compat</code>. The platform-identification
helpers
(<code>get_platform</code>/<code>get_host_platform</code>/<code>is_mingw</code>)
now live in <code>distutils.compilers.platform.detect</code> and the
macOS deployment-target logic and <code>compiler_fixup</code> in
<code>distutils.compilers.platform.macos</code>;
<code>CygwinCCompiler.gcc_version</code> returns a
<code>packaging.version.Version</code>. <code>distutils.util</code>
re-exports the platform/macOS helpers from their new homes for backward
compatibility rather than keeping duplicate copies. (sysconfig lookups
still route through distutils pending its own decoupling.) (<a
href="https://redirect.github.com/pypa/setuptools/issues/5268">#5268</a>)</li>
<li>The compilers now read their build configuration from the standard
library's <code>sysconfig</code> instead of
<code>distutils.sysconfig</code>. Per-compiler customization --
previously <code>distutils.sysconfig.customize_compiler</code> -- has
moved into <code>Compiler.configure_system()</code>: a no-op on the base
class, with <code>UnixCCompiler</code> applying the
compiler/flag/archiver settings CPython recorded in
<code>sysconfig</code> (and the usual
<code>CC</code>/<code>CFLAGS</code>/<code>LDSHARED</code>/… environment
overrides). <code>distutils.sysconfig.customize_compiler</code> is
retained as a thin wrapper that calls
<code>compiler.configure_system()</code>. (<a
href="https://redirect.github.com/pypa/setuptools/issues/5269">#5269</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/pypa/setuptools/commit/77b7ace0aaf660beeb9b3f9da4aabf21cbcf3fa9"><code>77b7ace</code></a>
Bump version: 84.0.0 → 84.1.0</li>
<li><a
href="https://github.com/pypa/setuptools/commit/e66004c64eaf6cf2f8c53ccdfac75cf7abce7ccf"><code>e66004c</code></a>
Merge pull request <a
href="https://redirect.github.com/pypa/setuptools/issues/5329">#5329</a>
from pypa/fix/wheel-namespace-path-escape</li>
<li><a
href="https://github.com/pypa/setuptools/commit/54b5dcadc1bd938a5a66919e8ab7bcd59805e7fd"><code>54b5dca</code></a>
Cover the containment check with a symlink escape test</li>
<li><a
href="https://github.com/pypa/setuptools/commit/6d07b8d17890dda58d944ac51e58f54af92d52af"><code>6d07b8d</code></a>
Validate namespace package names during legacy wheel installation</li>
<li><a
href="https://github.com/pypa/setuptools/commit/8419f006d4e34c4ecfc7a0bd1bc6993855adc3cc"><code>8419f00</code></a>
Merge pull request <a
href="https://redirect.github.com/pypa/setuptools/issues/5356">#5356</a>
from pypa/skeleton-ruff-0.16</li>
<li><a
href="https://github.com/pypa/setuptools/commit/6dadc86a3f7dbe1cf49c6301548ffde61d4e74e1"><code>6dadc86</code></a>
Remove DTZ011 suppressions that Ruff 0.17 reports as unused.</li>
<li><a
href="https://github.com/pypa/setuptools/commit/715ec38b46424533806195a96983e3dd70fdf3b9"><code>715ec38</code></a>
Merge <a
href="https://github.com/jaraco/skeleton">https://github.com/jaraco/skeleton</a></li>
<li><a
href="https://github.com/pypa/setuptools/commit/9695460ebfa879ada16545d62b26b9a489a6d5ed"><code>9695460</code></a><code>jaraco/skeleton#219</code></li>
<li><a
href="https://github.com/pypa/setuptools/commit/9e0812976b1154a7aece47188814385c887bc6c9"><code>9e08129</code></a><code>jaraco/skeleton#216</code></li>
<li><a
href="https://github.com/pypa/setuptools/commit/4e612f9e44ecfad130d0b2a27a2dce0740599b4b"><code>4e612f9</code></a>
Ignore PyPy's deprecated check_home warning in tests</li>
<li>Additional commits viewable in <a
href="https://github.com/pypa/setuptools/compare/v83.0.0...v84.1.0">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 03:03:53 +09:00
2f46bb3ca4 fix(sdk-py): preserve SSE data and cursor state across chunks (#9477)
Replaces https://github.com/langchain-ai/langgraph/pull/9451, which
GitHub would not reopen after the branch was rebased. Prior discussion
and review remain on that PR.

Preserve empty data lines and unnamed null events, emit CR-terminated
lines immediately, and retain decoded cursor IDs across reconnects.
Replace malformed UTF-8 consistently with JS, expose decoder state
through its constructor/read-only property, and suppress repeated
control placeholders.

Rebased onto the current reconnect/backoff fixes without changing
request header encoding, retry parsing, JSON-only payloads or tolerant
EOF behavior. Matching JS changes:
https://github.com/langchain-ai/langgraphjs/pull/3128.

### SSE references
- [MDN event stream format and
fields](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format):
UTF-8, comments, colonless fields and multiline data.
- [WHATWG
parsing](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream):
CR/LF/CRLF framing and buffering.
- [WHATWG
interpretation](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation):
BOM, data assembly and ID rules.
- [WHATWG
reconnection](https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model):
cursor propagation.

Native Python/JS JSON semantics remain language-specific; this does not
claim exhaustive conformance.

## References
-
[Plan](https://openswe.langchain.dev/agents/86db0ac8-b177-5c04-a1e5-e3226a37b42e/plan)
- [Slack
thread](https://langchain.slack.com/archives/C0C411URFR6/p1792789247110279)

Made by [Open SWE](https://github.com/langchain-ai/open-swe) · [view
thread](https://openswe.langchain.dev/agents/86db0ac8-b177-5c04-a1e5-e3226a37b42e)
· openai:gpt-6.1-sol (medium)

---------

Co-authored-by: Hunter Lovell <hntrl@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-28 10:53:22 -07:00
dependabot[bot] 4b7f2f5230 chore(deps): bump zod from 4.3.6 to 4.6.7 in /libs/cli/js-monorepo-example (#9431)
Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.6.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/colinhacks/zod/releases">zod's
releases</a>.</em></p>
<blockquote>
<h2>v4.6.7</h2>
<h2>Commits:</h2>
<ul>
<li>b500a2ee95134f81d74da7911d7378dd2853d042 4.6.7</li>
</ul>
<h2>v4.6.6</h2>
<blockquote>
<p>Six malicious commits pushed to <code>main</code> on September 21
with a compromised git credential were reverted the same day in <a
href="https://redirect.github.com/colinhacks/zod/issues/6620">#6620</a>
and have since been removed from the history of <code>main</code>. None
were ever published to npm. The npm provenance for 4.6.6 names 284243fb,
the pre-rewrite copy of 9c219833 with identical contents.</p>
</blockquote>
<h2>Commits:</h2>
<ul>
<li>0cd3af5e9f50ad11bbcd4407a46a21b8848ba574 docs: load the en locale
directly in Zod Mini</li>
<li>9d5e60f162997b764b40d45aa02481888db50dea docs: import locales from
zod/locales, not z.locales</li>
<li>34a626f307871ecbb0123947559328dc52ed5e3e docs: tighten the z.locales
tree-shaking note</li>
<li>74304ce5b5796b5edb9a88ceb8e170b6ea3e82df release: stage npm versions
instead of publishing them (<a
href="https://redirect.github.com/colinhacks/zod/issues/6622">#6622</a>)</li>
<li>b2ef983e0711ff6517d6020b021071e2e5653efa docs: add Code for Japan to
bronze sponsors</li>
<li>8fb2faf0584828a6a027d8279db36f6f52784462 docs: crop the Code for
Japan logo and list it first in bronze</li>
<li>bacad518c41610d654658172d325874bf0ee6290 docs: square the Code for
Japan logo to match the other bronze tiles</li>
<li>c42596bd1c87e941d7241d10e012c5c196adcc10 release: hold the run on
the release-approval environment until the staged versions are approved
(<a
href="https://redirect.github.com/colinhacks/zod/issues/6638">#6638</a>)</li>
<li>a2ba4a67986a3e6b2b131be30084a2f460d9ea67 fix(types): declare record
output and input as members, not base type arguments (<a
href="https://redirect.github.com/colinhacks/zod/issues/6645">#6645</a>)</li>
<li>a0ebf970f2d752e5f58527cd2942ff48be4f46bd fix(v4): check array, set
and map size bounds before parsing elements (<a
href="https://redirect.github.com/colinhacks/zod/issues/6652">#6652</a>)</li>
<li>529f78dd9431b07f49647fee3aa0819b3f8b66b7 4.6.6</li>
<li>9c219833c3a3764fa1f1ea9c5ecec8254a63c0b1 ci: stage at verbose so a
failed OIDC token exchange says why</li>
</ul>
<h2>v4.6.5</h2>
<h2>Commits:</h2>
<ul>
<li>d2b135cfb7a3582b9eb515756b9166bcb9521f4a docs: add the 4.6.x patch
highlights to the 4.6 post</li>
<li>f1448f7cee00df9fe1e9ad84a000aa1828cc8bc1 docs: fold the 4.6.x patch
highlights into the 4.6 post's own sections</li>
<li>de65a5cb39ed22a507fac935788f718fa88d104f docs: lead the properties
section with the check and add a Zod Mini tab (<a
href="https://redirect.github.com/colinhacks/zod/issues/6598">#6598</a>)</li>
<li>56222cd1532c07bcb91b67df529cab4c0a215330 feat(instanceof): key the
.properties() shape off the instance type (<a
href="https://redirect.github.com/colinhacks/zod/issues/6600">#6600</a>)</li>
<li>ca0229a404818290e6cdcfefcd7eb2d04bcbb543 Revert &quot;feat: add
z.currencyCode() over a vendored ISO 4217 list, refreshed weekly by CI
(<a
href="https://redirect.github.com/colinhacks/zod/issues/6595">#6595</a>)&quot;</li>
<li>cc4cd4ee9c52fcaa10964e48cc144541e41a5ed9 Revert &quot;Revert
&quot;feat: add z.currencyCode() over a vendored ISO 4217 list,
refreshed weekly by CI (<a
href="https://redirect.github.com/colinhacks/zod/issues/6595">#6595</a>)&quot;&quot;</li>
<li>0f3f5ee3ca56c7574bf849e54f79e9a6e02562ee 4.6.5</li>
<li>59bbc03e10c636b9eb3c393dfeb552819774ec21 chore: re-pin the
integration peers to the workspace zod after the 4.6.5 bump</li>
</ul>
<h2>v4.6.4</h2>
<p>A patch on top of <a
href="https://github.com/colinhacks/zod/releases/tag/v4.6.3">4.6.3</a>.</p>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/d6bc1e30"><code>d6bc1e30</code></a>
feat: add <code>z.currencyCode()</code> over a vendored ISO 4217 list,
refreshed weekly by CI (<a
href="https://redirect.github.com/colinhacks/zod/pull/6595">#6595</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/ad32d751"><code>ad32d751</code></a>
perf: <code>z.url()</code> rejects an invalid URL with
<code>URL.canParse()</code> instead of a throwing constructor, about 50x
faster; fewer allocations on the validation path (<a
href="https://redirect.github.com/colinhacks/zod/pull/6588">#6588</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/2bb08717"><code>2bb08717</code></a>
chore: re-pin the integration peers to the workspace zod after the 4.6.4
bump</li>
<li><a
href="https://github.com/colinhacks/zod/commit/f6e1701a"><code>f6e1701a</code></a>
chore(deps): bump next to 15.5.25 and vite to 7.3.6 (<a
href="https://redirect.github.com/colinhacks/zod/pull/6153">#6153</a>)</li>
</ul>
<h2>v4.6.3</h2>
<p>A patch on top of <a
href="https://github.com/colinhacks/zod/releases/tag/v4.6.2">4.6.2</a>.</p>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/413cce9a"><code>413cce9a</code></a>
fix(v4): make z.properties() a check again (<a
href="https://redirect.github.com/colinhacks/zod/pull/6594">#6594</a>) —
removes the standalone <code>z.properties()</code> schema from 4.6.0;
<code>z.instanceof().properties()</code> and
<code>.check(...z.properties())</code> are unchanged</li>
<li><a
href="https://github.com/colinhacks/zod/commit/75d63ee1"><code>75d63ee1</code></a>
docs: show only the <code>.properties()</code> method form in the 4.6
post</li>
<li><a
href="https://github.com/colinhacks/zod/commit/46da9572"><code>46da9572</code></a>
docs: match the error-message examples to what the parsers emit</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/b500a2ee95134f81d74da7911d7378dd2853d042"><code>b500a2e</code></a>
4.6.7</li>
<li><a
href="https://github.com/colinhacks/zod/commit/9c219833c3a3764fa1f1ea9c5ecec8254a63c0b1"><code>9c21983</code></a>
ci: stage at verbose so a failed OIDC token exchange says why</li>
<li><a
href="https://github.com/colinhacks/zod/commit/529f78dd9431b07f49647fee3aa0819b3f8b66b7"><code>529f78d</code></a>
4.6.6</li>
<li><a
href="https://github.com/colinhacks/zod/commit/a0ebf970f2d752e5f58527cd2942ff48be4f46bd"><code>a0ebf97</code></a>
fix(v4): check array, set and map size bounds before parsing elements
(<a
href="https://redirect.github.com/colinhacks/zod/issues/6652">#6652</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/a2ba4a67986a3e6b2b131be30084a2f460d9ea67"><code>a2ba4a6</code></a>
fix(types): declare record output and input as members, not base type
argumen...</li>
<li><a
href="https://github.com/colinhacks/zod/commit/c42596bd1c87e941d7241d10e012c5c196adcc10"><code>c42596b</code></a>
release: hold the run on the release-approval environment until the
staged ve...</li>
<li><a
href="https://github.com/colinhacks/zod/commit/bacad518c41610d654658172d325874bf0ee6290"><code>bacad51</code></a>
docs: square the Code for Japan logo to match the other bronze
tiles</li>
<li><a
href="https://github.com/colinhacks/zod/commit/8fb2faf0584828a6a027d8279db36f6f52784462"><code>8fb2faf</code></a>
docs: crop the Code for Japan logo and list it first in bronze</li>
<li><a
href="https://github.com/colinhacks/zod/commit/b2ef983e0711ff6517d6020b021071e2e5653efa"><code>b2ef983</code></a>
docs: add Code for Japan to bronze sponsors</li>
<li><a
href="https://github.com/colinhacks/zod/commit/74304ce5b5796b5edb9a88ceb8e170b6ea3e82df"><code>74304ce</code></a>
release: stage npm versions instead of publishing them (<a
href="https://redirect.github.com/colinhacks/zod/issues/6622">#6622</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/colinhacks/zod/compare/v4.3.6...v4.6.7">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for zod since your current version.</p>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 02:29:45 +09:00
Richard Scarrott 5a3e5dd739 fix(sdk-py): retry failed stream reconnects with backoff (#9462)
## Summary

A stream reconnect that fails to open (429, 5xx, transport error,
non-SSE response) is now retried with backoff instead of ending the
stream, e.g. while the server restarts or deploys.

```diff
 HttpClient.stream / SyncHttpClient.stream, on reconnect (GET to Location)
-  retry only a mid-read drop, with no delay, 5 times per stream
+  also retry a reconnect that fails to open: 429, 5xx, transport error, non-SSE
+  sleep 1, 2, 4, 5, 5s (+ up to 25% jitter) before each reconnect
+  reset the 5 attempts once a reconnect yields an event
+  give up with "Exceeded maximum…" raised from the last error
```

The first POST still raises on any error, since retrying it could start
a duplicate run. The backoff formula moves to `_shared/utilities.py`,
shared with the stream controllers.

The sleep also delays the first reconnect after a plain drop by about a
second, which matches the JS SDK. A run created without
`stream_resumable=True` can't replay what it sent while the client was
away, so it now loses about a second more of events. Resumable runs lose
nothing.

## Evidence

- **Before:** a reconnect hitting 429/502/503, a connect error or a
non-SSE response raised, a stream with 6 spaced-out drops gave up, and
reconnects never slept.
**After:** `test_client_stream.py` covers each (sync and async), plus
404 and first-POST failures staying fatal, and giving up after ~20s.

## Merge Danger

**Door:** two-way

**Blast Radius:** small (sdk-py stream reconnects only)
2026-10-28 11:46:39 +00:00
Hunter Lovell eb8ec111b3 fix(langgraph): emit node-output messages from a graph streamed inside a node (#9463)
When a node streams a graph with `stream_mode="messages"`, messages that
one of that graph's nodes *returns* (rather than streams from an LLM)
are dropped. The same graph streamed at the top level reports them:

```python
def respond(state: MessagesState) -dict:
    return {"messages": [AIMessage("hi there")]}

subgraph = StateGraph(MessagesState).add_node("respond", respond).add_edge(START, "respond").compile()

subgraph.stream(msgs, stream_mode="messages")          # -> "hi there"

def parent_node(state, config):
    for msg, _ in subgraph.stream(msgs, config, stream_mode="messages"):
        ...                                             # -> nothing
```

A graph streamed this way attaches its own messages handler with the
node's namespace as `parent_ns`, so it still reports that graph's own
messages with `subgraphs=False` (#5836). `on_chat_model_start` honors
`parent_ns`, but `on_chain_start` didn't, so node outputs were filtered
out as if they came from a subgraph. This applies the same check in
`on_chain_start`.

Split out of #9453 (see @eliornl's [review
comment](https://github.com/langchain-ai/langgraph/pull/9453#discussion_r…)).
2026-10-27 14:46:39 -07:00
Hunter Lovell 8c177f7388 fix(langgraph): respect subgraphs=False in RemoteGraph.stream under a streaming parent (#9453)
When a node streams a `RemoteGraph` with `subgraphs=False` while its
parent graph is itself streamed with `subgraphs=True`,
`RemoteGraph.stream`/`astream` yield the remote graph's subgraph events
to the caller. With `stream_mode="updates"`, the node gets `['model',
'child']` where an in-memory graph streamed the same way gives
`['child']`.

`RemoteGraph` requests `stream_subgraphs=True` whenever the config
carries a parent stream, so the parent sees the remote graph's subgraph
events. Those events are forwarded to the parent correctly, but the
values yielded to the caller were never filtered by the caller's own
`subgraphs` setting. Locally this falls out of how streams are wired:
with `subgraphs=False` the run never installs the caller's queue for its
subgraphs, so their events only reach the parent. This applies the same
split on the client: subgraph events still go to the parent, and only
reach the caller when it passed `subgraphs=True`.

`messages` events are left as they are. The server's messages handler
dedupes across namespaces, so once it has sent a message from inside a
subgraph it doesn't send the top-level copy a `subgraphs=False` caller
would see locally. Filtering would leave that caller with nothing, so it
keeps getting the subgraph tokens, as today. Matching local exactly
needs the server to produce the caller's view; I'll open an issue for
that.

The new tests fail on `main`.
2026-10-27 14:40:42 -07:00
55013146ae fix(sdk-py): match Auth.on overload to optional resources (#9450)
Match the `Auth.on` decorator-factory overload to its existing optional
`resources` argument so `auth.on()`, action-only calls, and
`resources=None` type-check. Add regression coverage for the overload
and wildcard registration; runtime authorization behavior is unchanged.

## References
- [Slack
thread](https://langchain.slack.com/archives/C0C411URFR6/p1792789013113819)

Made by [Open SWE](https://github.com/langchain-ai/open-swe) · [view
thread](https://openswe.langchain.dev/agents/094c284b-638a-5b04-95f0-623b47d0a52f)
· openai:gpt-6.1-sol (medium)

Co-authored-by: Hunter Lovell <hntrl@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-27 14:34:20 -07:00
43 changed files with 2392 additions and 636 deletions

No files matched your search

+3 -3
View File
@@ -2148,6 +2148,6 @@ yocto-queue@^0.1.0:
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
"zod@^3.25.76 || ^4":
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
version "4.6.7"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.6.7.tgz#e806cf64a7f4e37635ce9f2f52c04c3766f75e5b"
integrity sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==
+8
View File
@@ -11,6 +11,7 @@ import click.exceptions
import langgraph_cli.config
import langgraph_cli.docker
from langgraph_cli import langsmith_auth
from langgraph_cli.analytics import log_command
from langgraph_cli.config import Config
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
@@ -233,6 +234,13 @@ def cli():
pass
@cli.command(help="Log in to LangSmith in the browser (no API key needed).")
@click.option("--no-browser", is_flag=True, help="Print the URL instead of opening it.")
def login(no_browser: bool) -> None:
api_url = langsmith_auth.resolve_api_url(os.environ.get("LANGSMITH_ENDPOINT"))
langsmith_auth.login(api_url, open_browser=not no_browser)
# Wire the deploy group (defined in deploy.py) into the top-level CLI.
cli.add_command(deploy)
+26 -12
View File
@@ -1,6 +1,7 @@
"""Deploy command and subcommands for the LangGraph CLI."""
import base64
import functools
import json as json_mod
import os
import pathlib
@@ -19,6 +20,7 @@ import click.exceptions
from dotenv import dotenv_values, set_key
import langgraph_cli.config
from langgraph_cli import langsmith_auth
from langgraph_cli.analytics import log_command
from langgraph_cli.config import Config
from langgraph_cli.constants import DEFAULT_CONFIG
@@ -1982,17 +1984,6 @@ def _create_host_backend_client(
if val:
resolved_api_key = val
break
if not resolved_api_key:
if effective_no_input:
raise click.ClickException(
"No LangSmith API key found. Set LANGSMITH_API_KEY in the "
"environment or .env file."
)
click.secho(
"No LangSmith API key found. Create one at Settings > API Keys in LangSmith.",
fg="yellow",
)
resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True)
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
"LANGSMITH_TENANT_ID"
)
@@ -2000,8 +1991,31 @@ def _create_host_backend_client(
"LANGSMITH_ENDPOINT"
)
endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint)
access_token: Callable[[], str] | None = None
if not resolved_api_key:
profile = langsmith_auth.load_profile()
api_url = langsmith_auth.resolve_api_url(langsmith_endpoint)
if profile.get("api_key"):
resolved_api_key = profile["api_key"]
else:
if not any((profile.get("oauth") or {}).values()):
if effective_no_input:
raise click.ClickException(
"No LangSmith credentials found. Set LANGSMITH_API_KEY "
"in the environment or .env file (an API key), or run "
"`langgraph login`."
)
click.secho("You are not logged in to LangSmith.", fg="yellow")
langsmith_auth.login(api_url)
profile = langsmith_auth.load_profile()
access_token = functools.partial(langsmith_auth.get_access_token, api_url)
tenant_id = tenant_id or profile.get("workspace_id")
return HostBackendClient(
endpoints.control_plane_url, resolved_api_key, tenant_id=tenant_id
endpoints.control_plane_url,
resolved_api_key,
tenant_id=tenant_id,
**({"access_token": access_token} if access_token else {}),
)
+15 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
from urllib.parse import urlparse
@@ -130,17 +130,18 @@ class HostBackendClient:
def __init__(
self,
base_url: str,
api_key: str,
api_key: str | None,
tenant_id: str | None = None,
*,
access_token: Callable[[], str] | None = None,
transport: httpx.BaseTransport | None = None,
):
if not base_url:
raise click.UsageError("Host backend URL is required")
headers: dict[str, str] = {
"X-Api-Key": api_key,
"Accept": "application/json",
}
self._access_token = access_token
headers: dict[str, str] = {"Accept": "application/json"}
if api_key:
headers["X-Api-Key"] = api_key
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
self._endpoints = ControlPlaneEndpoints.from_control_plane_url(base_url)
@@ -170,8 +171,15 @@ class HostBackendClient:
payload: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
headers = (
{"Authorization": f"Bearer {self._access_token()}"}
if self._access_token
else None
)
try:
resp = self._client.request(method, path, json=payload, params=params)
resp = self._client.request(
method, path, json=payload, params=params, headers=headers
)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
detail = _error_detail(err.response)
+225
View File
@@ -0,0 +1,225 @@
"""LangSmith OAuth login, shared with the ``langsmith`` CLI via ``~/.langsmith/config.json``."""
from __future__ import annotations
import datetime
import json
import os
import pathlib
import time
import webbrowser
from typing import Any
from urllib.parse import urlparse
import click
import httpx
CLIENT_ID = "langsmith-cli"
DEFAULT_API_URL = "https://api.smith.langchain.com"
_WELL_KNOWN = "/.well-known/oauth-authorization-server"
_REFRESH_LEEWAY = datetime.timedelta(seconds=60)
_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
def _config_path() -> pathlib.Path:
override = os.environ.get("LANGSMITH_CONFIG_FILE")
if override:
return pathlib.Path(override)
return pathlib.Path.home() / ".langsmith" / "config.json"
def _read_config() -> dict[str, Any]:
try:
config = json.loads(_config_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return config if isinstance(config, dict) else {}
def _write_config(config: dict[str, Any]) -> None:
path = _config_path()
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
def _profile_name(config: dict[str, Any]) -> str:
return (
os.environ.get("LANGSMITH_PROFILE")
or config.get("current_profile")
or "default"
)
def load_profile() -> dict[str, Any]:
"""Return the active profile from the LangSmith config file, or ``{}``."""
config = _read_config()
profile = (config.get("profiles") or {}).get(_profile_name(config))
return profile if isinstance(profile, dict) else {}
def resolve_api_url(endpoint: str | None = None) -> str:
"""Pick the LangSmith API URL: explicit endpoint, then profile, then cloud."""
return endpoint or load_profile().get("api_url") or DEFAULT_API_URL
def _save_tokens(token: dict[str, Any], api_url: str | None = None) -> None:
config = _read_config()
name = _profile_name(config)
profile = config.setdefault("profiles", {}).setdefault(name, {})
if api_url:
profile["api_url"] = api_url
oauth = profile.setdefault("oauth", {})
oauth["access_token"] = token["access_token"]
if token.get("refresh_token"):
oauth["refresh_token"] = token["refresh_token"]
if token.get("expires_in"):
expires_at = datetime.datetime.now(datetime.timezone.utc) + (
datetime.timedelta(seconds=int(token["expires_in"]))
)
oauth["expires_at"] = expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
config.setdefault("current_profile", name)
_write_config(config)
def _oauth_endpoints(api_url: str) -> dict[str, str]:
"""Discover the OAuth endpoints, falling back to ``<root>/oauth/*``."""
root = api_url.rstrip("/")
for suffix in ("/api/v1", "/api"):
if root.endswith(suffix):
root = root[: -len(suffix)]
break
for base in (root, f"{root}/api"):
try:
resp = httpx.get(base + _WELL_KNOWN, timeout=10)
doc = resp.json() if resp.is_success else None
except (httpx.HTTPError, ValueError):
continue
if (
isinstance(doc, dict)
and str(doc.get("issuer", "")).rstrip("/") == base
and all(
urlparse(str(doc.get(key, ""))).netloc == urlparse(base).netloc
for key in ("token_endpoint", "device_authorization_endpoint")
)
):
return {
"issuer": base,
"token": doc["token_endpoint"],
"device": doc["device_authorization_endpoint"],
}
return {
"issuer": root,
"token": f"{root}/oauth/token",
"device": f"{root}/oauth/device/code",
}
def _expiring(oauth: dict[str, Any]) -> bool:
if not oauth.get("access_token"):
return True
expires_at = oauth.get("expires_at")
if not expires_at:
return False
try:
expiry = datetime.datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
except ValueError:
return True
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=datetime.timezone.utc)
return expiry <= datetime.datetime.now(datetime.timezone.utc) + _REFRESH_LEEWAY
def get_access_token(api_url: str) -> str:
"""Return a valid access token from the profile, refreshing it if needed."""
oauth = load_profile().get("oauth") or {}
if not _expiring(oauth):
return oauth["access_token"]
refresh_token = oauth.get("refresh_token")
if not refresh_token:
raise click.ClickException(
"LangSmith session expired. Run `langgraph login` to sign in again."
)
endpoints = _oauth_endpoints(api_url)
try:
resp = httpx.post(
endpoints["token"],
data={
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
},
timeout=10,
)
resp.raise_for_status()
token = resp.json()
except (httpx.HTTPError, ValueError):
raise click.ClickException(
"Could not refresh your LangSmith session. "
"Run `langgraph login` to sign in again."
) from None
if not isinstance(token, dict) or not token.get("access_token"):
raise click.ClickException(
"Could not refresh your LangSmith session. "
"Run `langgraph login` to sign in again."
)
_save_tokens(token)
return token["access_token"]
def login(api_url: str, *, open_browser: bool = True) -> None:
"""Sign in with the OAuth device flow and store tokens in the profile."""
endpoints = _oauth_endpoints(api_url)
try:
resp = httpx.post(
endpoints["device"],
data={"client_id": CLIENT_ID, "resource": endpoints["issuer"]},
timeout=10,
)
resp.raise_for_status()
device = resp.json()
device_code, user_code = device["device_code"], device["user_code"]
verification_url = (
device.get("verification_uri_complete") or device["verification_uri"]
)
except (httpx.HTTPError, ValueError, KeyError):
raise click.ClickException(
f"Could not start LangSmith login against {api_url}."
) from None
click.echo(f"Open this URL to authorize the CLI:\n{verification_url}\n")
click.echo(f"Code: {user_code}\n")
if open_browser:
webbrowser.open(verification_url)
click.echo("Waiting for authorization...")
interval = max(int(device.get("interval") or 5), 5)
deadline = time.monotonic() + int(device.get("expires_in") or 600)
while time.monotonic() < deadline:
time.sleep(interval)
try:
resp = httpx.post(
endpoints["token"],
data={
"grant_type": _DEVICE_GRANT,
"client_id": CLIENT_ID,
"device_code": device_code,
"resource": endpoints["issuer"],
},
timeout=10,
)
body = resp.json()
except (httpx.HTTPError, ValueError):
continue
if resp.is_success and body.get("access_token"):
_save_tokens(body, api_url)
click.secho("Logged in to LangSmith.", fg="green")
return
error = body.get("error") if isinstance(body, dict) else None
if error == "slow_down":
interval += 5
elif error != "authorization_pending":
raise click.ClickException(f"LangSmith login failed: {error or 'error'}.")
raise click.ClickException("Timed out waiting for LangSmith login.")
@@ -578,12 +578,13 @@ def test_a_deployed_result_prints_the_deployment_url(
class TestCreateHostBackendClientNoInput:
def test_raises_when_no_api_key_and_no_input(self, monkeypatch, tmp_path):
monkeypatch.setenv("LANGSMITH_CONFIG_FILE", str(tmp_path / "missing.json"))
monkeypatch.setattr(deploy_mod, "_no_input", True)
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
monkeypatch.delenv("LANGCHAIN_API_KEY", raising=False)
monkeypatch.delenv("LANGGRAPH_HOST_API_KEY", raising=False)
with pytest.raises(click.ClickException, match="API key"):
with pytest.raises(click.ClickException, match="credentials"):
_create_host_backend_client(
host_url="https://api.example.com",
api_key=None,
@@ -0,0 +1,191 @@
import json
from datetime import datetime, timedelta, timezone
import click
import httpx
import pytest
from langgraph_cli import deploy as deploy_mod
from langgraph_cli import langsmith_auth
from langgraph_cli.deploy import _create_host_backend_client
from langgraph_cli.host_backend import HostBackendClient
API_URL = "https://api.smith.langchain.com"
def _iso(delta: timedelta) -> str:
return (datetime.now(timezone.utc) + delta).strftime("%Y-%m-%dT%H:%M:%SZ")
@pytest.fixture
def config_file(tmp_path, monkeypatch):
path = tmp_path / "config.json"
monkeypatch.setenv("LANGSMITH_CONFIG_FILE", str(path))
monkeypatch.delenv("LANGSMITH_PROFILE", raising=False)
for name in ("LANGGRAPH_HOST_API_KEY", "LANGSMITH_API_KEY", "LANGCHAIN_API_KEY"):
monkeypatch.delenv(name, raising=False)
return path
def _write_profile(path, **oauth):
path.write_text(
json.dumps(
{
"current_profile": "default",
"profiles": {
"default": {
"api_url": API_URL,
"workspace_id": "ws-1",
"oauth": oauth,
}
},
}
)
)
def _no_discovery(monkeypatch):
def get(*_a, **_k):
raise httpx.ConnectError("no discovery")
monkeypatch.setattr(httpx, "get", get)
def test_valid_token_is_returned_without_network(config_file, monkeypatch):
_write_profile(
config_file, access_token="tok", expires_at=_iso(timedelta(minutes=5))
)
def boom(*_a, **_k):
raise AssertionError("no network expected")
monkeypatch.setattr(httpx, "get", boom)
monkeypatch.setattr(httpx, "post", boom)
assert langsmith_auth.get_access_token(API_URL) == "tok"
def test_expired_token_is_refreshed_and_saved(config_file, monkeypatch):
_write_profile(
config_file,
access_token="old",
refresh_token="refresh-1",
expires_at=_iso(timedelta(seconds=-5)),
)
_no_discovery(monkeypatch)
seen = {}
def post(url, data, timeout):
seen.update(url=url, data=data)
return httpx.Response(
200,
json={
"access_token": "new",
"refresh_token": "refresh-2",
"expires_in": 300,
},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx, "post", post)
assert langsmith_auth.get_access_token(API_URL) == "new"
assert seen["url"] == f"{API_URL}/oauth/token"
assert seen["data"]["refresh_token"] == "refresh-1"
saved = json.loads(config_file.read_text())["profiles"]["default"]["oauth"]
assert saved["access_token"] == "new"
assert saved["refresh_token"] == "refresh-2"
def test_expired_token_without_refresh_token_asks_to_log_in(config_file):
_write_profile(
config_file, access_token="old", expires_at=_iso(timedelta(seconds=-5))
)
with pytest.raises(click.ClickException, match="langgraph login"):
langsmith_auth.get_access_token(API_URL)
def test_login_stores_tokens_after_pending(config_file, monkeypatch):
_no_discovery(monkeypatch)
monkeypatch.setattr(langsmith_auth.time, "sleep", lambda _s: None)
monkeypatch.setattr(langsmith_auth.webbrowser, "open", lambda _u: True)
token_responses = iter(
[
{"error": "authorization_pending"},
{"access_token": "tok", "refresh_token": "r", "expires_in": 300},
]
)
def post(url, data, timeout):
request = httpx.Request("POST", url)
if url.endswith("/oauth/device/code"):
return httpx.Response(
200,
json={
"device_code": "dc",
"user_code": "ABCD",
"verification_uri": "https://example.com/activate",
"expires_in": 600,
"interval": 5,
},
request=request,
)
body = next(token_responses)
return httpx.Response(
400 if "error" in body else 200, json=body, request=request
)
monkeypatch.setattr(httpx, "post", post)
langsmith_auth.login(API_URL)
profile = json.loads(config_file.read_text())["profiles"]["default"]
assert profile["api_url"] == API_URL
assert profile["oauth"]["access_token"] == "tok"
def test_client_sends_bearer_token_per_request():
seen = []
def handler(req: httpx.Request) -> httpx.Response:
seen.append(dict(req.headers))
return httpx.Response(200, json={"resources": []})
tokens = iter(["t1", "t2"])
client = HostBackendClient(
"https://api.example.com",
None,
access_token=lambda: next(tokens),
transport=httpx.MockTransport(handler),
)
client.list_deployments()
client.list_deployments()
assert [h["authorization"] for h in seen] == ["Bearer t1", "Bearer t2"]
assert "x-api-key" not in seen[0]
def test_factory_uses_logged_in_profile(config_file, monkeypatch):
_write_profile(
config_file, access_token="tok", expires_at=_iso(timedelta(minutes=5))
)
monkeypatch.setattr(deploy_mod, "_no_input", True)
monkeypatch.delenv("LANGSMITH_TENANT_ID", raising=False)
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
client = _create_host_backend_client(
host_url="https://api.example.com", api_key=None, env_vars={}
)
assert client._access_token is not None
assert client._access_token() == "tok"
assert client._client.headers["x-tenant-id"] == "ws-1"
assert "x-api-key" not in client._client.headers
def test_factory_without_credentials_and_no_input_fails(config_file, monkeypatch):
monkeypatch.setattr(deploy_mod, "_no_input", True)
with pytest.raises(click.ClickException, match="langgraph login"):
_create_host_backend_client(
host_url="https://api.example.com", api_key=None, env_vars={}
)
+1 -6
View File
@@ -255,11 +255,6 @@ R = TypeVar("R")
S = TypeVar("S")
class _EntrypointPregel(Pregel):
"""The graph that `@entrypoint` builds. `Pregel` itself cannot be constructed
directly."""
# The decorator was wrapped in a class to support the `final` attribute.
# In this form, the `final` attribute should play nicely with IDE autocompletion,
# and type checking tools.
@@ -578,7 +573,7 @@ class entrypoint(Generic[ContextT]):
else:
output_type = save_type = sig.return_annotation
graph: Pregel[Any, ContextT, Any, Any] = _EntrypointPregel(
graph: Pregel[Any, ContextT, Any, Any] = Pregel(
nodes={
func.__name__: PregelNode(
bound=bound,
+8 -11
View File
@@ -22,7 +22,7 @@ from langchain_core.messages import (
)
from typing_extensions import TypedDict, deprecated
from langgraph._internal._constants import CONF, CONFIG_KEY_SEND, NS_SEP
from langgraph._internal._constants import CONF, CONFIG_KEY_SEND
from langgraph.graph.state import StateGraph
from langgraph.pregel._messages import (
_messages_delta_reducer as _messages_delta_reducer,
@@ -343,7 +343,7 @@ def push_message(
)
from langgraph.config import get_config
from langgraph.pregel._messages import StreamMessagesHandler
from langgraph.pregel._messages import StreamMessagesHandler, graph_namespace
config = get_config()
message = next(x for x in convert_to_messages([message]))
@@ -359,15 +359,12 @@ def push_message(
):
handlers = config["callbacks"]
if stream_handler := next(
(x for x in handlers if isinstance(x, StreamMessagesHandler)), None
):
metadata = config["metadata"]
message_meta = (
tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP)),
metadata,
)
stream_handler._emit(message_meta, message, dedupe=False)
metadata = config["metadata"]
for handler in handlers:
if isinstance(handler, StreamMessagesHandler):
ns = graph_namespace(metadata)
if handler.subgraphs or not ns or ns == handler.parent_ns:
handler._emit((ns, metadata), message, dedupe=False)
if state_key:
config[CONF][CONFIG_KEY_SEND]([(state_key, message)])
+2 -2
View File
@@ -1,3 +1,3 @@
from langgraph.pregel.main import Pregel
from langgraph.pregel.main import NodeBuilder, Pregel
__all__ = ("Pregel",)
__all__ = ("Pregel", "NodeBuilder")
+21 -8
View File
@@ -23,7 +23,7 @@ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResul
from pydantic import BaseModel
from langgraph._internal._config import filter_to_user_tags
from langgraph._internal._constants import NS_SEP
from langgraph._internal._constants import NS_SEP, PUSH
from langgraph.channels.binop import _get_overwrite
from langgraph.channels.delta import DeltaChannel
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
@@ -55,6 +55,23 @@ def _state_values(obj: Any) -> Sequence[Any]:
return ()
def graph_namespace(metadata: Mapping[str, Any]) -> tuple[str, ...]:
"""Namespace of the graph a task runs in, from the task's run metadata.
Drops the task's own segment, plus one per enclosing `@task` call: a call
runs in its caller's graph but nests its segment under the caller's. A
call's `langgraph_path` is `(PUSH, <caller's path>, <index>, True)`, while
a `Send` task's ends in `False`.
"""
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))
path = metadata.get("langgraph_path", ())
drop = 1
while len(path) == 4 and path[0] == PUSH and path[-1] is True:
drop += 1
path = path[1]
return ns[:-drop]
class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
"""A callback handler that implements stream_mode=messages.
@@ -148,9 +165,7 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
**kwargs: Any,
) -> Any:
if metadata and (not tags or (TAG_NOSTREAM not in tags)):
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
:-1
]
ns = graph_namespace(metadata)
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
return
if (filtered_tags := filter_to_user_tags(tags)) is not None:
@@ -213,10 +228,8 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
and kwargs.get("name") == metadata.get("langgraph_node")
and (not tags or TAG_HIDDEN not in tags)
):
ns = tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP))[
:-1
]
if not self.subgraphs and len(ns) > 0:
ns = graph_namespace(metadata)
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
return
self.metadata[run_id] = (ns, metadata)
for value in _state_values(inputs):
+7 -12
View File
@@ -7,9 +7,9 @@ from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler
from langgraph._internal._constants import NS_SEP
from langgraph.constants import TAG_NOSTREAM
from langgraph.errors import GraphDrained, GraphInterrupt
from langgraph.pregel._messages import graph_namespace
from langgraph.pregel.protocol import StreamChunk
try:
@@ -92,12 +92,9 @@ class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
) -> tuple[str, ...] | None:
"""Resolve the namespace this tool call should emit at, or `None` to skip.
Mirrors `StreamMessagesHandler.on_chat_model_start`'s namespace
derivation: parses `langgraph_checkpoint_ns` (which ends with
the `node_name:task_id` of the calling node), drops that
trailing segment, and returns the containing subgraph's own
namespace. Returns `None` when the call should be silently
suppressed:
Mirrors `StreamMessagesHandler.on_chat_model_start`: the namespace
is that of the graph the tool runs in (`graph_namespace`). Returns
`None` when the call should be silently suppressed:
- `metadata` is missing — handler is attached to a context
without Pregel routing info.
@@ -110,11 +107,9 @@ class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
return None
if tags and TAG_NOSTREAM in tags:
return None
nskey = metadata.get("langgraph_checkpoint_ns")
if not nskey:
ns: tuple[str, ...] = ()
else:
ns = tuple(cast(str, nskey).split(NS_SEP))[:-1]
ns = (
graph_namespace(metadata) if metadata.get("langgraph_checkpoint_ns") else ()
)
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
return None
return ns
+3 -1
View File
@@ -47,7 +47,9 @@ def validate_graph(
f"not in known channels: '{repr(sorted(channels))[:100]}'"
)
else:
raise TypeError(f"Invalid node type {type(node)}, expected PregelNode")
raise TypeError(
f"Invalid node type {type(node)}, expected PregelNode or NodeBuilder"
)
for chan in subscribed_channels:
if chan not in channels:
+482 -57
View File
@@ -17,6 +17,7 @@ from collections.abc import (
Sequence,
)
from dataclasses import is_dataclass, replace
from datetime import timedelta
from functools import partial
from inspect import isclass
from typing import (
@@ -34,6 +35,7 @@ from langchain_core.globals import get_debug
from langchain_core.runnables import (
RunnableSequence,
)
from langchain_core.runnables.base import Input, Output
from langchain_core.runnables.config import (
RunnableConfig,
get_async_callback_manager_for_config,
@@ -91,6 +93,13 @@ from langgraph._internal._queue import ( # type: ignore[attr-defined]
AsyncQueue,
SyncQueue,
)
from langgraph._internal._runnable import (
Runnable,
RunnableLike,
RunnableSeq,
coerce_to_runnable,
)
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.callbacks import (
GraphInterruptEvent,
@@ -146,7 +155,7 @@ from langgraph.pregel._messages import (
StreamMessagesHandlerV2,
ensure_message_ids,
)
from langgraph.pregel._read import PregelNode
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._retry import RetryPolicy
from langgraph.pregel._runner import PregelRunner
from langgraph.pregel._task_status import read_task_statuses
@@ -156,6 +165,7 @@ from langgraph.pregel._utils import (
validate_timeout_supported,
)
from langgraph.pregel._validate import validate_graph, validate_keys
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
from langgraph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtocol
from langgraph.runtime import (
@@ -187,6 +197,7 @@ from langgraph.types import (
StateUpdate,
StreamMode,
StreamPart,
TimeoutPolicy,
ensure_valid_checkpointer,
)
from langgraph.typing import ContextT, InputT, OutputT, StateT
@@ -197,7 +208,182 @@ try:
except ImportError:
_StreamingCallbackHandler = None # type: ignore
__all__ = ("Pregel",)
__all__ = ("NodeBuilder", "Pregel")
_WriteValue = Callable[[Input], Output] | Any
class NodeBuilder:
__slots__ = (
"_channels",
"_triggers",
"_tags",
"_metadata",
"_writes",
"_bound",
"_retry_policy",
"_cache_policy",
"_timeout",
)
_channels: str | list[str]
_triggers: list[str]
_tags: list[str]
_metadata: dict[str, Any]
_writes: list[ChannelWriteEntry]
_bound: Runnable
_retry_policy: list[RetryPolicy]
_cache_policy: CachePolicy | None
_timeout: TimeoutPolicy | None
def __init__(
self,
) -> None:
self._channels = []
self._triggers = []
self._tags = []
self._metadata = {}
self._writes = []
self._bound = DEFAULT_BOUND
self._retry_policy = []
self._cache_policy = None
self._timeout = None
def subscribe_only(
self,
channel: str,
) -> Self:
"""Subscribe to a single channel."""
if not self._channels:
self._channels = channel
else:
raise ValueError(
"Cannot subscribe to single channels when other channels are already subscribed to"
)
self._triggers.append(channel)
return self
def subscribe_to(
self,
*channels: str,
read: bool = True,
) -> Self:
"""Add channels to subscribe to.
Node will be invoked when any of these channels are updated, with a dict of the
channel values as input.
Args:
channels: Channel name(s) to subscribe to
read: If `True`, the channels will be included in the input to the node.
Otherwise, they will trigger the node without being sent in input.
Returns:
Self for chaining
"""
if isinstance(self._channels, str):
raise ValueError(
"Cannot subscribe to channels when subscribed to a single channel"
)
if read:
if not self._channels:
self._channels = list(channels)
else:
self._channels.extend(channels)
if isinstance(channels, str):
self._triggers.append(channels)
else:
self._triggers.extend(channels)
return self
def read_from(
self,
*channels: str,
) -> Self:
"""Adds the specified channels to read from, without subscribing to them."""
assert isinstance(self._channels, list), (
"Cannot read additional channels when subscribed to single channels"
)
self._channels.extend(channels)
return self
def do(
self,
node: RunnableLike,
) -> Self:
"""Adds the specified node."""
if self._bound is not DEFAULT_BOUND:
self._bound = RunnableSeq(
self._bound, coerce_to_runnable(node, name=None, trace=True)
)
else:
self._bound = coerce_to_runnable(node, name=None, trace=True)
return self
def write_to(
self,
*channels: str | ChannelWriteEntry,
**kwargs: _WriteValue,
) -> Self:
"""Add channel writes.
Args:
*channels: Channel names to write to.
**kwargs: Channel name and value mappings.
Returns:
Self for chaining
"""
self._writes.extend(
ChannelWriteEntry(c) if isinstance(c, str) else c for c in channels
)
self._writes.extend(
ChannelWriteEntry(k, mapper=v)
if callable(v)
else ChannelWriteEntry(k, value=v)
for k, v in kwargs.items()
)
return self
def meta(self, *tags: str, **metadata: Any) -> Self:
"""Add tags or metadata to the node."""
self._tags.extend(tags)
self._metadata.update(metadata)
return self
def add_retry_policies(self, *policies: RetryPolicy) -> Self:
"""Adds retry policies to the node."""
self._retry_policy.extend(policies)
return self
def add_cache_policy(self, policy: CachePolicy) -> Self:
"""Adds cache policies to the node."""
self._cache_policy = policy
return self
def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
"""Set the per-attempt timeout policy for this node."""
self._timeout = coerce_timeout_policy(timeout)
return self
def build(self) -> PregelNode:
"""Builds the node."""
return PregelNode(
channels=self._channels,
triggers=self._triggers,
tags=self._tags,
metadata=self._metadata,
writers=[ChannelWrite(self._writes)],
bound=self._bound,
retry_policy=self._retry_policy,
cache_policy=self._cache_policy,
timeout=self._timeout,
)
# Kwargs that ``stream_events(version="v3")`` / ``astream_events(version="v3")``
@@ -275,22 +461,255 @@ class Pregel(
PregelProtocol[StateT, ContextT, InputT, OutputT],
Generic[StateT, ContextT, InputT, OutputT],
):
"""The compiled graph that runs a LangGraph application.
"""Pregel manages the runtime behavior for LangGraph applications.
`StateGraph.compile()` and the `@entrypoint` decorator return an instance of
this class. It provides the methods that run and inspect the application,
such as `invoke`, `stream`, `get_state` and `update_state`.
## Overview
The runtime is named after Google's
[Pregel algorithm](https://research.google/pubs/pub37252/). It runs the
application in steps. In each step it plans which nodes to run, runs them in
parallel, and then applies their writes to the graph's state, so writes
become visible only in the next step. The run ends when no node is
scheduled, or when the recursion limit is reached.
Pregel combines [**actors**](https://en.wikipedia.org/wiki/Actor_model)
and **channels** into a single application.
**Actors** read data from channels and write data to channels.
Pregel organizes the execution of the application into multiple steps,
following the **Pregel Algorithm**/**Bulk Synchronous Parallel** model.
`Pregel` is not constructed directly. Build graphs with
[`StateGraph`][langgraph.graph.StateGraph] or
[`@entrypoint`][langgraph.func.entrypoint].
Each step consists of three phases:
- **Plan**: Determine which **actors** to execute in this step. For example,
in the first step, select the **actors** that subscribe to the special
**input** channels; in subsequent steps,
select the **actors** that subscribe to channels updated in the previous step.
- **Execution**: Execute all selected **actors** in parallel,
until all complete, or one fails, or a timeout is reached. During this
phase, channel updates are invisible to actors until the next step.
- **Update**: Update the channels with the values written by the **actors**
in this step.
Repeat until no **actors** are selected for execution, or a maximum number of
steps is reached.
## Actors
An **actor** is a `PregelNode`.
It subscribes to channels, reads data from them, and writes data to them.
It can be thought of as an **actor** in the Pregel algorithm.
`PregelNodes` implement LangChain's
Runnable interface.
## Channels
Channels are used to communicate between actors (`PregelNodes`).
Each channel has a value type, an update type, and an update function – which
takes a sequence of updates and
modifies the stored value. Channels can be used to send data from one chain to
another, or to send data from a chain to itself in a future step. LangGraph
provides a number of built-in channels:
### Basic channels: LastValue and Topic
- `LastValue`: The default channel, stores the last value sent to the channel,
useful for input and output values, or for sending data from one step to the next
- `Topic`: A configurable PubSub Topic, useful for sending multiple values
between *actors*, or for accumulating output. Can be configured to deduplicate
values, and/or to accumulate values over the course of multiple steps.
### Advanced channels: Context and BinaryOperatorAggregate
- `Context`: exposes the value of a context manager, managing its lifecycle.
Useful for accessing external resources that require setup and/or teardown. e.g.
`client = Context(httpx.Client)`
- `BinaryOperatorAggregate`: stores a persistent value, updated by applying
a binary operator to the current value and each update
sent to the channel, useful for computing aggregates over multiple steps. e.g.
`total = BinaryOperatorAggregate(int, operator.add)`
## Examples
Most users will interact with Pregel via a
[StateGraph (Graph API)][langgraph.graph.StateGraph] or via an
[entrypoint (Functional API)][langgraph.func.entrypoint].
However, for **advanced** use cases, Pregel can be used directly. If you're
not sure whether you need to use Pregel directly, then the answer is probably no
- you should use the Graph API or Functional API instead. These are higher-level
interfaces that will compile down to Pregel under the hood.
Here are some examples to give you a sense of how it works:
Example: Single node application
```python
from langgraph.channels import EphemeralValue
from langgraph.pregel import Pregel, NodeBuilder
node1 = (
NodeBuilder().subscribe_only("a")
.do(lambda x: x + x)
.write_to("b")
)
app = Pregel(
nodes={"node1": node1},
channels={
"a": EphemeralValue(str),
"b": EphemeralValue(str),
},
input_channels=["a"],
output_channels=["b"],
)
app.invoke({"a": "foo"})
```
```con
{'b': 'foofoo'}
```
Example: Using multiple nodes and multiple output channels
```python
from langgraph.channels import LastValue, EphemeralValue
from langgraph.pregel import Pregel, NodeBuilder
node1 = (
NodeBuilder().subscribe_only("a")
.do(lambda x: x + x)
.write_to("b")
)
node2 = (
NodeBuilder().subscribe_to("b")
.do(lambda x: x["b"] + x["b"])
.write_to("c")
)
app = Pregel(
nodes={"node1": node1, "node2": node2},
channels={
"a": EphemeralValue(str),
"b": LastValue(str),
"c": EphemeralValue(str),
},
input_channels=["a"],
output_channels=["b", "c"],
)
app.invoke({"a": "foo"})
```
```con
{'b': 'foofoo', 'c': 'foofoofoofoo'}
```
Example: Using a Topic channel
```python
from langgraph.channels import LastValue, EphemeralValue, Topic
from langgraph.pregel import Pregel, NodeBuilder
node1 = (
NodeBuilder().subscribe_only("a")
.do(lambda x: x + x)
.write_to("b", "c")
)
node2 = (
NodeBuilder().subscribe_only("b")
.do(lambda x: x + x)
.write_to("c")
)
app = Pregel(
nodes={"node1": node1, "node2": node2},
channels={
"a": EphemeralValue(str),
"b": EphemeralValue(str),
"c": Topic(str, accumulate=True),
},
input_channels=["a"],
output_channels=["c"],
)
app.invoke({"a": "foo"})
```
```pycon
{"c": ["foofoo", "foofoofoofoo"]}
```
Example: Using a `BinaryOperatorAggregate` channel
```python
from langgraph.channels import EphemeralValue, BinaryOperatorAggregate
from langgraph.pregel import Pregel, NodeBuilder
node1 = (
NodeBuilder().subscribe_only("a")
.do(lambda x: x + x)
.write_to("b", "c")
)
node2 = (
NodeBuilder().subscribe_only("b")
.do(lambda x: x + x)
.write_to("c")
)
def reducer(current, update):
if current:
return current + " | " + update
else:
return update
app = Pregel(
nodes={"node1": node1, "node2": node2},
channels={
"a": EphemeralValue(str),
"b": EphemeralValue(str),
"c": BinaryOperatorAggregate(str, operator=reducer),
},
input_channels=["a"],
output_channels=["c"],
)
app.invoke({"a": "foo"})
```
```con
{'c': 'foofoo | foofoofoofoo'}
```
Example: Introducing a cycle
This example demonstrates how to introduce a cycle in the graph, by having
a chain write to a channel it subscribes to.
Execution will continue until a `None` value is written to the channel.
```python
from langgraph.channels import EphemeralValue
from langgraph.pregel import Pregel, NodeBuilder, ChannelWriteEntry
example_node = (
NodeBuilder()
.subscribe_only("value")
.do(lambda x: x + x if len(x) < 10 else None)
.write_to(ChannelWriteEntry(channel="value", skip_none=True))
)
app = Pregel(
nodes={"example_node": example_node},
channels={
"value": EphemeralValue(str),
},
input_channels=["value"],
output_channels=["value"],
)
app.invoke({"value": "a"})
```
```con
{'value': 'aaaaaaaaaaaaaaaa'}
```
"""
nodes: dict[str, PregelNode]
@@ -349,7 +768,7 @@ class Pregel(
def __init__(
self,
*,
nodes: dict[str, PregelNode],
nodes: dict[str, PregelNode | NodeBuilder],
channels: dict[str, BaseChannel | ManagedValueSpec] | None,
auto_validate: bool = True,
stream_mode: StreamMode = "values",
@@ -374,13 +793,6 @@ class Pregel(
stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
**deprecated_kwargs: Unpack[DeprecatedKwargs],
) -> None:
# LangGraph builds graphs through subclasses: `CompiledStateGraph` and
# the graph `@entrypoint` returns.
if type(self) is Pregel:
raise TypeError(
"`Pregel` cannot be constructed directly. "
"Build graphs with `StateGraph` or `@entrypoint`."
)
if (
config_type := deprecated_kwargs.get("config_type", MISSING)
) is not MISSING:
@@ -395,7 +807,9 @@ class Pregel(
checkpointer = ensure_valid_checkpointer(checkpointer)
self.nodes = dict(nodes)
self.nodes = {
k: v.build() if isinstance(v, NodeBuilder) else v for k, v in nodes.items()
}
self.channels = channels or {}
if TASKS in self.channels and not isinstance(self.channels[TASKS], Topic):
raise ValueError(
@@ -2466,6 +2880,18 @@ class Pregel(
for h in callback_manager.inheritable_handlers
if not isinstance(h, StreamMessagesHandlerV2)
]
if CONFIG_KEY_TASK_ID not in config[CONF]:
# Not run as a task (a child invoked with its own thread, say),
# this graph's namespace starts at the root, so a caller's
# handler would take this graph's events for its own graph's.
callback_manager.handlers = [
h for h in callback_manager.handlers if not _is_stream_handler(h)
]
callback_manager.inheritable_handlers = [
h
for h in callback_manager.inheritable_handlers
if not _is_stream_handler(h)
]
if "ls_integration" not in callback_manager.metadata:
callback_manager.add_metadata({"ls_integration": "langgraph"})
run_manager = callback_manager.on_chain_start(
@@ -2484,9 +2910,9 @@ class Pregel(
)
# set up subgraph checkpointing
config = self._own_checkpoint_config(config)
stream_handlers: list[StreamMessagesHandler | StreamToolCallHandler] = []
# set up messages stream mode
if "messages" in stream_modes:
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
use_stream_messages_v2 = bool(
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
)
@@ -2495,24 +2921,12 @@ class Pregel(
if use_stream_messages_v2
else StreamMessagesHandler
)
run_manager.inheritable_handlers.append(
messages_handler_cls(
stream.put,
subgraphs,
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
)
)
stream_handlers.append(messages_handler_cls(stream.put, subgraphs))
# set up tools stream mode
if "tools" in stream_modes:
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
run_manager.inheritable_handlers.append(
StreamToolCallHandler(
stream.put,
subgraphs,
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
)
)
stream_handlers.append(StreamToolCallHandler(stream.put, subgraphs))
run_manager.inheritable_handlers.extend(stream_handlers)
# set up custom stream mode
if "custom" in stream_modes:
@@ -2595,6 +3009,10 @@ class Pregel(
cache_policy=self.cache_policy,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop:
# Known only now: the loop numbers the namespace of every
# subgraph a task runs after its first.
for handler in stream_handlers:
handler.parent_ns = loop.checkpoint_ns
emit_graph_lifecycle_events(loop)
# create runner
runner = PregelRunner(
@@ -2877,6 +3295,18 @@ class Pregel(
for h in callback_manager.inheritable_handlers
if not isinstance(h, StreamMessagesHandlerV2)
]
if CONFIG_KEY_TASK_ID not in config[CONF]:
# Not run as a task (a child invoked with its own thread, say),
# this graph's namespace starts at the root, so a caller's
# handler would take this graph's events for its own graph's.
callback_manager.handlers = [
h for h in callback_manager.handlers if not _is_stream_handler(h)
]
callback_manager.inheritable_handlers = [
h
for h in callback_manager.inheritable_handlers
if not _is_stream_handler(h)
]
if "ls_integration" not in callback_manager.metadata:
callback_manager.add_metadata({"ls_integration": "langgraph"})
run_manager = await callback_manager.on_chain_start(
@@ -2911,10 +3341,9 @@ class Pregel(
)
# set up subgraph checkpointing
config = self._own_checkpoint_config(config)
stream_handlers: list[StreamMessagesHandler | StreamToolCallHandler] = []
# set up messages stream mode
if "messages" in stream_modes:
# namespace can be None in a root level graph?
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
use_stream_messages_v2 = bool(
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
)
@@ -2923,24 +3352,12 @@ class Pregel(
if use_stream_messages_v2
else StreamMessagesHandler
)
run_manager.inheritable_handlers.append(
messages_handler_cls(
stream_put,
subgraphs,
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
)
)
stream_handlers.append(messages_handler_cls(stream_put, subgraphs))
# set up tools stream mode
if "tools" in stream_modes:
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
run_manager.inheritable_handlers.append(
StreamToolCallHandler(
stream_put,
subgraphs,
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
)
)
stream_handlers.append(StreamToolCallHandler(stream_put, subgraphs))
run_manager.inheritable_handlers.extend(stream_handlers)
# set up custom stream mode
def stream_writer(c: Any) -> None:
@@ -3049,6 +3466,10 @@ class Pregel(
cache_policy=self.cache_policy,
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
) as loop:
# Known only now: the loop numbers the namespace of every
# subgraph a task runs after its first.
for handler in stream_handlers:
handler.parent_ns = loop.checkpoint_ns
await aemit_graph_lifecycle_events(loop)
# create runner
runner = PregelRunner(
@@ -4046,6 +4467,10 @@ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> Non
payload["values"] = mapper(payload["values"])
def _is_stream_handler(handler: object) -> bool:
return isinstance(handler, (StreamMessagesHandler, StreamToolCallHandler))
def _resolve_parent_ns(
graph_config: RunnableConfig | None, call_config: RunnableConfig | None
) -> tuple[str, ...]:
+10
View File
@@ -847,6 +847,11 @@ class RemoteGraph(PregelProtocol):
# filter for what was actually requested
if mode not in requested:
continue
# subgraph events may have been requested only for the parent stream.
# messages are exempt: the server dedupes them across namespaces, so
# dropping them here would also drop the root-level message
if not subgraphs and NS_SEP in chunk.event and mode != "messages":
continue
if chunk.event.startswith("messages"):
chunk = chunk._replace(data=tuple(chunk.data))
@@ -997,6 +1002,11 @@ class RemoteGraph(PregelProtocol):
# filter for what was actually requested
if mode not in requested:
continue
# subgraph events may have been requested only for the parent stream.
# messages are exempt: the server dedupes them across namespaces, so
# dropping them here would also drop the root-level message
if not subgraphs and NS_SEP in chunk.event and mode != "messages":
continue
if chunk.event.startswith("messages"):
chunk = chunk._replace(data=tuple(chunk.data))
-222
View File
@@ -1,222 +0,0 @@
"""Build graphs directly on the Pregel engine, for tests of engine behavior.
LangGraph does not expose this: applications build graphs with `StateGraph` or
`@entrypoint`. Tests use it to set up channels, triggers and writes that those
APIs do not produce on their own.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
from typing import Any
from langchain_core.runnables.base import Input, Output
from typing_extensions import Self
from langgraph._internal._runnable import (
Runnable,
RunnableLike,
RunnableSeq,
coerce_to_runnable,
)
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph.pregel import Pregel
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
__all__ = ("NodeBuilder", "RawPregel")
_WriteValue = Callable[[Input], Output] | Any
class NodeBuilder:
"""Builds a `PregelNode` that reads and writes named channels."""
__slots__ = (
"_channels",
"_triggers",
"_tags",
"_metadata",
"_writes",
"_bound",
"_retry_policy",
"_cache_policy",
"_timeout",
)
_channels: str | list[str]
_triggers: list[str]
_tags: list[str]
_metadata: dict[str, Any]
_writes: list[ChannelWriteEntry]
_bound: Runnable
_retry_policy: list[RetryPolicy]
_cache_policy: CachePolicy | None
_timeout: TimeoutPolicy | None
def __init__(
self,
) -> None:
self._channels = []
self._triggers = []
self._tags = []
self._metadata = {}
self._writes = []
self._bound = DEFAULT_BOUND
self._retry_policy = []
self._cache_policy = None
self._timeout = None
def subscribe_only(
self,
channel: str,
) -> Self:
"""Subscribe to a single channel."""
if not self._channels:
self._channels = channel
else:
raise ValueError(
"Cannot subscribe to single channels when other channels are already subscribed to"
)
self._triggers.append(channel)
return self
def subscribe_to(
self,
*channels: str,
read: bool = True,
) -> Self:
"""Add channels to subscribe to.
Node will be invoked when any of these channels are updated, with a dict of the
channel values as input.
Args:
channels: Channel name(s) to subscribe to
read: If `True`, the channels will be included in the input to the node.
Otherwise, they will trigger the node without being sent in input.
Returns:
Self for chaining
"""
if isinstance(self._channels, str):
raise ValueError(
"Cannot subscribe to channels when subscribed to a single channel"
)
if read:
if not self._channels:
self._channels = list(channels)
else:
self._channels.extend(channels)
if isinstance(channels, str):
self._triggers.append(channels)
else:
self._triggers.extend(channels)
return self
def read_from(
self,
*channels: str,
) -> Self:
"""Adds the specified channels to read from, without subscribing to them."""
assert isinstance(self._channels, list), (
"Cannot read additional channels when subscribed to single channels"
)
self._channels.extend(channels)
return self
def do(
self,
node: RunnableLike,
) -> Self:
"""Adds the specified node."""
if self._bound is not DEFAULT_BOUND:
self._bound = RunnableSeq(
self._bound, coerce_to_runnable(node, name=None, trace=True)
)
else:
self._bound = coerce_to_runnable(node, name=None, trace=True)
return self
def write_to(
self,
*channels: str | ChannelWriteEntry,
**kwargs: _WriteValue,
) -> Self:
"""Add channel writes.
Args:
*channels: Channel names to write to.
**kwargs: Channel name and value mappings.
Returns:
Self for chaining
"""
self._writes.extend(
ChannelWriteEntry(c) if isinstance(c, str) else c for c in channels
)
self._writes.extend(
ChannelWriteEntry(k, mapper=v)
if callable(v)
else ChannelWriteEntry(k, value=v)
for k, v in kwargs.items()
)
return self
def meta(self, *tags: str, **metadata: Any) -> Self:
"""Add tags or metadata to the node."""
self._tags.extend(tags)
self._metadata.update(metadata)
return self
def add_retry_policies(self, *policies: RetryPolicy) -> Self:
"""Adds retry policies to the node."""
self._retry_policy.extend(policies)
return self
def add_cache_policy(self, policy: CachePolicy) -> Self:
"""Adds cache policies to the node."""
self._cache_policy = policy
return self
def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
"""Set the per-attempt timeout policy for this node."""
self._timeout = coerce_timeout_policy(timeout)
return self
def build(self) -> PregelNode:
"""Builds the node."""
return PregelNode(
channels=self._channels,
triggers=self._triggers,
tags=self._tags,
metadata=self._metadata,
writers=[ChannelWrite(self._writes)],
bound=self._bound,
retry_policy=self._retry_policy,
cache_policy=self._cache_policy,
timeout=self._timeout,
)
class RawPregel(Pregel):
"""A `Pregel` built from hand-written nodes and channels.
Accepts `NodeBuilder` values in `nodes`, and builds them.
"""
def __init__(self, *, nodes: dict[str, PregelNode | NodeBuilder], **kwargs: Any):
super().__init__(
nodes={
k: v.build() if isinstance(v, NodeBuilder) else v
for k, v in nodes.items()
},
**kwargs,
)
@@ -19,8 +19,8 @@ from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Command, Durability, Overwrite
from tests.raw_pregel import NodeBuilder, RawPregel
pytestmark = pytest.mark.anyio
@@ -279,7 +279,7 @@ def _pregel_graph(seen: list[list[str | None]]) -> Any:
.do(look)
.write_to("messages")
)
return RawPregel(
return Pregel(
nodes={"look": node},
channels={
"messages": DeltaChannel(_messages_delta_reducer),
@@ -7,9 +7,8 @@ from langgraph.checkpoint.memory import InMemorySaver
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.pregel import Pregel
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Durability
from tests.raw_pregel import NodeBuilder, RawPregel
pytestmark = pytest.mark.anyio
@@ -20,7 +19,7 @@ def _sorted_extend(current: list, writes: list) -> list:
def _delta_input_graph() -> Pregel:
node = NodeBuilder().subscribe_only("go").do(lambda _: [2]).write_to("log", "plain")
return RawPregel(
return Pregel(
nodes={"n": node},
channels={
"log": DeltaChannel(_sorted_extend),
@@ -3,8 +3,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.pregel import Pregel
from tests.raw_pregel import NodeBuilder, RawPregel
from langgraph.pregel import NodeBuilder, Pregel
pytestmark = pytest.mark.anyio
@@ -18,7 +17,7 @@ def _extend(current: list, writes: list) -> list:
def _graph(reads: list) -> Pregel:
writer = NodeBuilder().subscribe_only("a").do(lambda _: [1]).write_to("d")
reader = NodeBuilder().subscribe_only("d").do(lambda d: reads.append(list(d)))
return RawPregel(
return Pregel(
nodes={"writer": writer, "reader": reader},
channels={"a": LastValue(str), "d": DeltaChannel(_extend)},
input_channels="a",
@@ -13,9 +13,8 @@ from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import Pregel
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Overwrite
from tests.raw_pregel import NodeBuilder, RawPregel
pytestmark = pytest.mark.anyio
@@ -37,7 +36,7 @@ def _extend(current: list, writes: list) -> list:
def _delta_input_graph(saver: InMemorySaver) -> Pregel:
node = NodeBuilder().subscribe_only("go").do(lambda _: [2]).write_to("log")
return RawPregel(
return Pregel(
nodes={"n": node},
channels={"log": DeltaChannel(_extend), "go": LastValue(int)},
input_channels=["log", "go"],
@@ -30,8 +30,8 @@ from langgraph.channels.delta import DeltaChannel
from langgraph.channels.last_value import LastValue
from langgraph.graph import START, StateGraph
from langgraph.graph.message import _messages_delta_reducer
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import Overwrite, StateSnapshot, StateUpdate
from tests.raw_pregel import NodeBuilder, RawPregel
pytestmark = pytest.mark.anyio
@@ -549,7 +549,7 @@ def _sorted_extend(current: list, writes: list) -> list:
def _delta_input_graph(snapshot_frequency: int = 1000) -> Any:
node = NodeBuilder().subscribe_only("go").do(lambda _: [2]).write_to("log", "plain")
return RawPregel(
return Pregel(
nodes={"n": node},
channels={
"log": DeltaChannel(_sorted_extend, snapshot_frequency=snapshot_frequency),
@@ -649,7 +649,7 @@ def _messages_input_graph() -> Any:
.do(lambda _: [HumanMessage("n", id="n")])
.write_to("messages")
)
return RawPregel(
return Pregel(
nodes={"n": node},
channels={
"messages": DeltaChannel(_messages_delta_reducer),
+2 -2
View File
@@ -13,13 +13,13 @@ from langgraph.errors import NodeInterrupt
from langgraph.func import entrypoint, task
from langgraph.graph import StateGraph
from langgraph.graph.message import MessageGraph
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import GraphOutput, Interrupt, RetryPolicy
from langgraph.warnings import (
LangGraphDeprecatedSinceV05,
LangGraphDeprecatedSinceV10,
LangGraphDeprecatedSinceV11,
)
from tests.raw_pregel import NodeBuilder, RawPregel
class PlainState(TypedDict): ...
@@ -157,7 +157,7 @@ def test_config_type_deprecation_pregel(mocker: MockerFixture) -> None:
LangGraphDeprecatedSinceV10,
match="`config_type` is deprecated and will be removed. Please use `context_schema` instead.",
):
instance = RawPregel(
instance = Pregel(
nodes={
"one": chain,
},
+2 -2
View File
@@ -39,6 +39,7 @@ from langgraph.channels.untracked_value import UntrackedValue
from langgraph.constants import END, START
from langgraph.graph import StateGraph
from langgraph.graph.message import MessagesState, add_messages
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import (
Command,
Durability,
@@ -61,7 +62,6 @@ from tests.messages import (
_AnyIdHumanMessage,
_AnyIdToolMessage,
)
from tests.raw_pregel import NodeBuilder, RawPregel
def test_invoke_two_processes_in_out_interrupt(
@@ -72,7 +72,7 @@ def test_invoke_two_processes_in_out_interrupt(
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox")
two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"inbox": LastValue(int),
@@ -39,6 +39,7 @@ from langgraph.constants import END, START
from langgraph.graph import MessagesState
from langgraph.graph.message import add_messages
from langgraph.graph.state import StateGraph
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.types import PregelTask, Send, StateSnapshot, StreamWriter
from tests.any_int import AnyInt
from tests.any_str import AnyDict, AnyStr, UnsortedSequence
@@ -50,7 +51,6 @@ from tests.messages import (
_AnyIdHumanMessage,
_AnyIdToolMessage,
)
from tests.raw_pregel import NodeBuilder, RawPregel
pytestmark = pytest.mark.anyio
@@ -61,7 +61,7 @@ async def test_invoke_two_processes_in_out_interrupt(
add_one = mocker.Mock(side_effect=lambda x: x + 1)
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox")
two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"inbox": LastValue(int),
+60 -25
View File
@@ -66,7 +66,10 @@ from langgraph.errors import GraphRecursionError, InvalidUpdateError, ParentComm
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages
from langgraph.pregel import Pregel
from langgraph.pregel import (
NodeBuilder,
Pregel,
)
from langgraph.pregel._loop import PregelLoop, SyncPregelLoop
from langgraph.pregel._runner import PregelRunner
from langgraph.runtime import RunControl
@@ -92,7 +95,6 @@ from tests.messages import (
_AnyIdHumanMessage,
_AnyIdToolMessage,
)
from tests.raw_pregel import NodeBuilder, RawPregel
pytestmark = pytest.mark.anyio
@@ -255,7 +257,7 @@ def test_context_json_schema() -> None:
x: int
y: str = field(default="foo")
app = RawPregel(
app = Pregel(
nodes={
"one": chain,
},
@@ -447,7 +449,7 @@ def test_invoke_single_process_in_out(mocker: MockerFixture) -> None:
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={
"one": chain,
},
@@ -483,7 +485,7 @@ def test_invoke_single_process_in_write_kwargs(mocker: MockerFixture) -> None:
.write_to("output", fixed=5, output_plus_one=lambda x: x + 1)
)
app = RawPregel(
app = Pregel(
nodes={"one": chain},
channels={
"input": LastValue(int),
@@ -519,7 +521,7 @@ def test_invoke_single_process_in_out_dict(mocker: MockerFixture) -> None:
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": chain},
channels={"input": LastValue(int), "output": LastValue(int)},
input_channels="input",
@@ -544,7 +546,7 @@ def test_invoke_single_process_in_dict_out_dict(mocker: MockerFixture) -> None:
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": chain},
channels={"input": LastValue(int), "output": LastValue(int)},
input_channels=["input"],
@@ -570,7 +572,7 @@ def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox")
two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"inbox": LastValue(int),
@@ -675,7 +677,7 @@ def test_batch_two_processes_in_out() -> None:
one = NodeBuilder().subscribe_only("input").do(add_one_with_delay).write_to("one")
two = NodeBuilder().subscribe_only("one").do(add_one_with_delay).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"one": LastValue(int),
@@ -707,7 +709,7 @@ def test_invoke_many_processes_in_out(mocker: MockerFixture) -> None:
)
nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes=nodes,
channels={str(i): LastValue(int) for i in range(-1, test_size - 2)}
| {"input": LastValue(int), "output": LastValue(int)},
@@ -735,7 +737,7 @@ def test_batch_many_processes_in_out(mocker: MockerFixture) -> None:
)
nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes=nodes,
channels={str(i): LastValue(int) for i in range(-1, test_size - 2)}
| {"input": LastValue(int), "output": LastValue(int)},
@@ -768,7 +770,7 @@ def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture) -> N
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={"output": LastValue(int), "input": LastValue(int)},
input_channels="input",
@@ -801,7 +803,7 @@ def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> Non
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"input": LastValue(int),
@@ -842,7 +844,7 @@ def test_invoke_checkpoint_two(
.do(raise_if_above_10)
)
app = RawPregel(
app = Pregel(
nodes={"one": one},
channels={
"total": BinaryOperatorAggregate(int, operator.add),
@@ -1449,7 +1451,7 @@ def test_invoke_checkpoint_three(
.do(raise_if_above_10)
)
app = RawPregel(
app = Pregel(
nodes={"one": one},
channels={
"total": BinaryOperatorAggregate(int, operator.add),
@@ -1574,7 +1576,7 @@ def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None
NodeBuilder().subscribe_only("inbox").do(add_10_each).write_to("output")
)
app = RawPregel(
app = Pregel(
nodes={
"one": one,
"chain_three": chain_three,
@@ -1605,7 +1607,7 @@ def test_invoke_join_then_call_other_pregel(
add_one = mocker.Mock(side_effect=lambda x: x + 1)
add_10_each = mocker.Mock(side_effect=lambda x: [y + 10 for y in x])
inner_app = RawPregel(
inner_app = Pregel(
nodes={
"one": NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
},
@@ -1626,7 +1628,7 @@ def test_invoke_join_then_call_other_pregel(
)
chain_three = NodeBuilder().subscribe_only("outbox_one").do(sum).write_to("output")
app = RawPregel(
app = Pregel(
nodes={
"one": one,
"two": two,
@@ -1667,7 +1669,7 @@ def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture) -> None:
)
two = NodeBuilder().subscribe_only("between").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"input": LastValue(int),
@@ -1694,7 +1696,7 @@ def test_invoke_two_processes_no_out(mocker: MockerFixture) -> None:
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("between")
two = NodeBuilder().subscribe_only("between").do(add_one)
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"input": LastValue(int),
@@ -1717,7 +1719,7 @@ def test_invoke_two_processes_no_in(mocker: MockerFixture) -> None:
two = NodeBuilder().subscribe_only("between").do(add_one)
with pytest.raises(TypeError):
RawPregel(nodes={"one": one, "two": two})
Pregel(nodes={"one": one, "two": two})
def test_conditional_entrypoint_to_multiple_state_graph(
@@ -4126,7 +4128,7 @@ def test_xray_lance(snapshot: SnapshotAssertion):
def test_channel_values(sync_checkpointer: BaseCheckpointSaver) -> None:
config = {"configurable": {"thread_id": "1"}}
chain = NodeBuilder().subscribe_only("input").write_to("output")
app = RawPregel(
app = Pregel(
nodes={
"one": chain,
},
@@ -8948,6 +8950,39 @@ def test_subgraph_streaming_sync() -> None:
assert result["num_chunks"] == 9
def test_subgraph_streaming_node_output_messages_sync() -> None:
"""Messages returned by a node (not streamed by an LLM) are emitted when a
graph is explicitly streamed with `stream_mode="messages"` inside a node."""
def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage("hi there", id="ai1")]}
subgraph = (
StateGraph(MessagesState)
.add_node("respond", respond)
.add_edge(START, "respond")
.compile()
)
msgs = {"messages": [("user", "hello")]}
top_level = [msg for msg, _ in subgraph.stream(msgs, stream_mode="messages")]
def parent_node(state: MessagesState, config: RunnableConfig) -> dict:
nonlocal in_node
in_node = [
msg for msg, _ in subgraph.stream(msgs, config, stream_mode="messages")
]
return {}
in_node: list = []
StateGraph(MessagesState).add_node("parent", parent_node).add_edge(
START, "parent"
).compile().invoke(msgs)
assert [m.content for m in top_level] == ["hi there"]
assert [m.content for m in in_node] == ["hi there"]
def test_get_graph_nonterminal_last_step_source(snapshot: SnapshotAssertion) -> None:
class State(TypedDict):
messages: list[str]
@@ -9505,7 +9540,7 @@ class _IntVersionSaver(InMemorySaver):
def _build_chain_after_a_delta_channel() -> Pregel:
return RawPregel(
return Pregel(
nodes={
"a": NodeBuilder().subscribe_only("inp").do(lambda _: ["a"]).write_to("d"),
"b": NodeBuilder().subscribe_only("d").do(lambda _: "b").write_to("x"),
@@ -9550,7 +9585,7 @@ async def test_aupdate_state_after_an_exit_snapshot_infers_the_last_writer() ->
def test_update_state_after_a_fork_seal_infers_the_subscriber_that_ran() -> None:
graph = RawPregel(
graph = Pregel(
nodes={
"a": NodeBuilder()
.subscribe_only("inp")
@@ -9585,7 +9620,7 @@ def test_update_state_after_a_fork_seal_infers_the_subscriber_that_ran() -> None
def test_update_state_after_a_fork_seal_infers_the_node_whose_read_it_advanced() -> (
None
):
graph = RawPregel(
graph = Pregel(
nodes={
"a": NodeBuilder().subscribe_only("inp").do(lambda _: ["a"]).write_to("d"),
"b": NodeBuilder().subscribe_only("d").do(lambda _: "b").write_to("out"),
+54 -16
View File
@@ -69,6 +69,7 @@ from langgraph.errors import (
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import MessagesState, _messages_delta_reducer, add_messages
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.pregel._loop import AsyncPregelLoop, PregelLoop
from langgraph.pregel._runner import PregelRunner
from langgraph.runtime import RunControl
@@ -94,7 +95,6 @@ from tests.messages import (
_AnyIdHumanMessage,
_AnyIdToolMessage,
)
from tests.raw_pregel import NodeBuilder, RawPregel
logger = logging.getLogger(__name__)
@@ -1446,7 +1446,7 @@ async def test_invoke_single_process_in_out(mocker: MockerFixture) -> None:
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={
"one": chain,
},
@@ -1479,7 +1479,7 @@ async def test_invoke_single_process_in_write_kwargs(mocker: MockerFixture) -> N
.write_to("output", fixed=5, output_plus_one=lambda x: x + 1)
)
app = RawPregel(
app = Pregel(
nodes={"one": chain},
channels={
"input": LastValue(int),
@@ -1515,7 +1515,7 @@ async def test_invoke_single_process_in_out_dict(mocker: MockerFixture) -> None:
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": chain},
channels={"input": LastValue(int), "output": LastValue(int)},
input_channels="input",
@@ -1540,7 +1540,7 @@ async def test_invoke_single_process_in_dict_out_dict(mocker: MockerFixture) ->
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": chain},
channels={"input": LastValue(int), "output": LastValue(int)},
input_channels=["input"],
@@ -1567,7 +1567,7 @@ async def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("inbox")
two = NodeBuilder().subscribe_only("inbox").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"inbox": LastValue(int),
@@ -1607,7 +1607,7 @@ async def test_batch_two_processes_in_out() -> None:
one = NodeBuilder().subscribe_only("input").do(add_one_with_delay).write_to("one")
two = NodeBuilder().subscribe_only("one").do(add_one_with_delay).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"one": LastValue(int),
@@ -1639,7 +1639,7 @@ async def test_invoke_many_processes_in_out(mocker: MockerFixture) -> None:
)
nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes=nodes,
channels={str(i): LastValue(int) for i in range(-1, test_size - 2)}
| {"input": LastValue(int), "output": LastValue(int)},
@@ -1668,7 +1668,7 @@ async def test_batch_many_processes_in_out(mocker: MockerFixture) -> None:
)
nodes["last"] = NodeBuilder().subscribe_only(str(i)).do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes=nodes,
channels={str(i): LastValue(int) for i in range(-1, test_size - 2)}
| {"input": LastValue(int), "output": LastValue(int)},
@@ -1704,7 +1704,7 @@ async def test_invoke_two_processes_two_in_two_out_invalid(
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={"output": LastValue(int), "input": LastValue(int)},
input_channels="input",
@@ -1722,7 +1722,7 @@ async def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture)
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
two = NodeBuilder().subscribe_only("input").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"input": LastValue(int),
@@ -1763,7 +1763,7 @@ async def test_invoke_checkpoint(
.do(raise_if_above_10)
)
app = RawPregel(
app = Pregel(
nodes={"one": one},
channels={
"total": BinaryOperatorAggregate(int, operator.add),
@@ -3641,7 +3641,7 @@ async def test_invoke_checkpoint_three(
.do(raise_if_above_10)
)
app = RawPregel(
app = Pregel(
nodes={"one": one},
channels={
"total": BinaryOperatorAggregate(int, operator.add),
@@ -3765,7 +3765,7 @@ async def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -
NodeBuilder().subscribe_only("inbox").do(add_10_each).write_to("output")
)
app = RawPregel(
app = Pregel(
nodes={
"one": one,
"chain_three": chain_three,
@@ -3799,7 +3799,7 @@ async def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture) -> Non
)
two = NodeBuilder().subscribe_only("between").do(add_one).write_to("output")
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"input": LastValue(int),
@@ -3823,7 +3823,7 @@ async def test_invoke_two_processes_no_out(mocker: MockerFixture) -> None:
one = NodeBuilder().subscribe_only("input").do(add_one).write_to("between")
two = NodeBuilder().subscribe_only("between").do(add_one)
app = RawPregel(
app = Pregel(
nodes={"one": one, "two": two},
channels={
"input": LastValue(int),
@@ -9503,6 +9503,44 @@ async def test_subgraph_streaming_async() -> None:
assert result["num_chunks"] == 9
async def test_subgraph_streaming_node_output_messages_async() -> None:
"""Messages returned by a node (not streamed by an LLM) are emitted when a
graph is explicitly streamed with `stream_mode="messages"` inside a node."""
async def respond(state: MessagesState) -> dict:
return {"messages": [AIMessage("hi there", id="ai1")]}
subgraph = (
StateGraph(MessagesState)
.add_node("respond", respond)
.add_edge(START, "respond")
.compile()
)
msgs = {"messages": [("user", "hello")]}
top_level = [msg async for msg, _ in subgraph.astream(msgs, stream_mode="messages")]
async def parent_node(state: MessagesState, config: RunnableConfig) -> dict:
nonlocal in_node
in_node = [
msg
async for msg, _ in subgraph.astream(msgs, config, stream_mode="messages")
]
return {}
in_node: list = []
await (
StateGraph(MessagesState)
.add_node("parent", parent_node)
.add_edge(START, "parent")
.compile()
.ainvoke(msgs)
)
assert [m.content for m in top_level] == ["hi there"]
assert [m.content for m in in_node] == ["hi there"]
@NEEDS_CONTEXTVARS
async def test_null_resume_disallowed_with_multiple_interrupts(
async_checkpointer: BaseCheckpointSaver,
@@ -1,56 +0,0 @@
"""`Pregel` is the type of compiled graphs, built only by `StateGraph` and
`@entrypoint`."""
import pytest
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import InMemorySaver
from typing_extensions import TypedDict
import langgraph.pregel
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.func import entrypoint
from langgraph.graph import START, StateGraph
from langgraph.pregel import Pregel
from langgraph.pregel._read import PregelNode
def test_pregel_cannot_be_constructed_directly() -> None:
with pytest.raises(TypeError, match="`Pregel` cannot be constructed directly"):
Pregel(
nodes={"node": PregelNode(channels="a", triggers=["a"])},
channels={"a": EphemeralValue(str)},
input_channels="a",
output_channels="a",
)
def test_node_builder_is_not_exported() -> None:
assert langgraph.pregel.__all__ == ("Pregel",)
assert not hasattr(langgraph.pregel, "NodeBuilder")
def test_compiled_graphs_are_pregel_instances() -> None:
class State(TypedDict):
value: int
def increment(state: State) -> State:
return {"value": state["value"] + 1}
builder = StateGraph(State)
builder.add_node("increment", increment)
builder.add_edge(START, "increment")
graph = builder.compile(checkpointer=InMemorySaver())
@entrypoint(checkpointer=InMemorySaver())
def workflow(value: int) -> int:
return value + 1
assert isinstance(graph, Pregel)
assert isinstance(workflow, Pregel)
# `with_config` copies the graph by calling its constructor again.
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
assert graph.with_config(recursion_limit=5).invoke({"value": 1}, config) == {
"value": 2
}
assert workflow.with_config(recursion_limit=5).invoke(1, config) == 2
+83
View File
@@ -16,9 +16,11 @@ from langgraph_sdk.schema import StreamPart
from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph._internal._constants import CONFIG_KEY_STREAM
from langgraph.errors import GraphInterrupt
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
from langgraph.pregel import Pregel
from langgraph.pregel.protocol import StreamProtocol
from langgraph.pregel.remote import RemoteGraph
from langgraph.types import Interrupt, StateSnapshot
from tests.any_str import AnyStr
@@ -863,6 +865,87 @@ async def test_astream_subgraphs_keeps_caller_namespace():
assert stream_parts == _CALLER_PREFIXED
# Subgraph events requested only on behalf of a streaming parent graph
_PARENT_STREAMED_PARTS = [
StreamPart(event="updates|sub:abc", data={"inner": {"chunk": "inner"}}),
StreamPart(event="messages|sub:abc", data=[{"content": "token"}, {}]),
StreamPart(event="updates", data={"sub": {"chunk": "root"}}),
]
def _parent_streamed_config(parent_parts: list) -> RunnableConfig:
return {
"configurable": {
"thread_id": "thread_1",
"checkpoint_ns": "outer:123",
CONFIG_KEY_STREAM: StreamProtocol(
parent_parts.append, {"updates", "messages"}
),
}
}
def test_stream_without_subgraphs_skips_parent_requested_subgraph_events():
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = _PARENT_STREAMED_PARTS
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
for stream_mode, expected in [
("updates", [{"sub": {"chunk": "root"}}]),
# messages are not filtered: the server dedupes them across namespaces
("messages", [({"content": "token"}, {})]),
]:
parent_parts: list = []
stream_parts = list(
remote_pregel.stream(
{"input": "data"},
_parent_streamed_config(parent_parts),
stream_mode=stream_mode,
)
)
assert stream_parts == expected
# subgraph events are still requested and forwarded to the parent
assert mock_sync_client.runs.stream.call_args.kwargs["stream_subgraphs"]
assert [ns for ns, _, _ in parent_parts] == [
("outer:123", "sub:abc"),
("outer:123", "sub:abc"),
("outer:123",),
]
async def test_astream_without_subgraphs_skips_parent_requested_subgraph_events():
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = _PARENT_STREAMED_PARTS
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client)
for stream_mode, expected in [
("updates", [{"sub": {"chunk": "root"}}]),
# messages are not filtered: the server dedupes them across namespaces
("messages", [({"content": "token"}, {})]),
]:
parent_parts: list = []
stream_parts = [
part
async for part in remote_pregel.astream(
{"input": "data"},
_parent_streamed_config(parent_parts),
stream_mode=stream_mode,
)
]
assert stream_parts == expected
# subgraph events are still requested and forwarded to the parent
assert mock_async_client.runs.stream.call_args.kwargs["stream_subgraphs"]
assert [ns for ns, _, _ in parent_parts] == [
("outer:123", "sub:abc"),
("outer:123", "sub:abc"),
("outer:123",),
]
def test_invoke():
# set up test
mock_sync_client = MagicMock()
+9 -9
View File
@@ -47,6 +47,7 @@ from langgraph.errors import (
)
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.pregel._read import PregelNode
from langgraph.pregel._retry import (
_checkpoint_ns_for_parent_command,
@@ -66,7 +67,6 @@ from langgraph.types import (
TimeoutPolicy,
interrupt,
)
from tests.raw_pregel import NodeBuilder, RawPregel
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
@@ -1260,7 +1260,7 @@ def test_pregel_validate_rejects_sync_writer_timeout():
return value
with pytest.raises(ValueError, match="only supported for async nodes"):
RawPregel(
Pregel(
nodes={
"slow": PregelNode(
channels="input",
@@ -1284,7 +1284,7 @@ def test_pregel_validate_rejects_wrapped_sync_runnable_lambda_timeout():
return value + 1
with pytest.raises(ValueError, match="only supported for async nodes"):
RawPregel(
Pregel(
nodes={
"slow": (
NodeBuilder()
@@ -1307,7 +1307,7 @@ def test_pregel_validate_accepts_wrapped_async_runnable_lambda_timeout():
async def slow(value: int) -> int:
return value + 1
RawPregel(
Pregel(
nodes={
"slow": (
NodeBuilder()
@@ -1334,7 +1334,7 @@ def test_pregel_validate_rejects_parallel_sync_branch_timeout():
return value + 1
with pytest.raises(ValueError, match="only supported for async nodes"):
RawPregel(
Pregel(
nodes={
"parallel": (
NodeBuilder()
@@ -1363,7 +1363,7 @@ def test_pregel_validate_rejects_sync_node_timeout():
return value + 1
with pytest.raises(ValueError, match="only supported for async nodes"):
RawPregel(
Pregel(
nodes={
"slow": (
NodeBuilder()
@@ -1388,7 +1388,7 @@ async def test_pregel_validate_accepts_async_runnable_lambda_timeout():
await asyncio.sleep(0.2)
return value + 1
graph = RawPregel(
graph = Pregel(
nodes={
"slow": (
NodeBuilder()
@@ -1418,7 +1418,7 @@ async def test_pregel_validate_accepts_runnable_callable_with_sync_and_async_tim
await asyncio.sleep(0.2)
return value + 1
graph = RawPregel(
graph = Pregel(
nodes={
"slow": (
NodeBuilder()
@@ -1632,7 +1632,7 @@ async def test_node_builder_timeout_e2e():
await asyncio.sleep(0.2)
return value + 1
graph = RawPregel(
graph = Pregel(
nodes={
"slow": (
NodeBuilder()
@@ -0,0 +1,308 @@
import sys
from typing import Any
import pytest
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.func import entrypoint, task
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.graph.message import push_message
from langgraph.graph.state import CompiledStateGraph
pytestmark = pytest.mark.anyio
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
@tool
def echo(text: str) -> str:
"""Echo the text."""
return text
def _one_node_graph(node: Any) -> CompiledStateGraph:
return (
StateGraph(MessagesState)
.add_node(node)
.add_edge(START, node.__name__)
.compile()
)
def _chat_and_echo(state: MessagesState) -> dict:
echo.invoke({"text": "done"})
return {"messages": [GenericFakeChatModel(messages=iter(["one two"])).invoke("hi")]}
async def _achat_and_echo(state: MessagesState) -> dict:
await echo.ainvoke({"text": "done"})
model = GenericFakeChatModel(messages=iter(["one two"]))
return {"messages": [await model.ainvoke("hi")]}
def _segment_names(ns: tuple[str, ...]) -> tuple[str, ...]:
return tuple(segment.split(":")[0] for segment in ns)
def _summary(part: dict) -> tuple[tuple[str, ...], str]:
if part["type"] == "messages":
return _segment_names(part["ns"]), part["data"][0].content
return _segment_names(part["ns"]), part["data"]["event"]
_CHAT_AND_ECHO_EVENTS = [
((), "tool-started"),
((), "tool-finished"),
((), "one"),
((), " "),
((), "two"),
]
_CHILD_CONFIGS = pytest.mark.parametrize(
"child_config",
[
None,
{"configurable": {"thread_id": "child"}},
{"configurable": {"thread_id": "parent"}},
],
ids=["no_config", "own_thread", "caller_thread"],
)
_TASK_CALLERS = pytest.mark.parametrize(
"nested", [False, True], ids=["from_entrypoint", "from_task_in_task"]
)
@_TASK_CALLERS
@pytest.mark.parametrize("subgraphs", [False, True])
def test_task_events_stream_under_the_graph_namespace(
nested: bool, subgraphs: bool
) -> None:
@task
def chat_and_echo() -> None:
_chat_and_echo({"messages": []})
@task
def outer() -> None:
chat_and_echo().result()
@entrypoint()
def workflow(inputs: dict) -> None:
(outer() if nested else chat_and_echo()).result()
parts = workflow.stream(
{}, stream_mode=["messages", "tools"], subgraphs=subgraphs, version="v2"
)
assert [_summary(part) for part in parts] == _CHAT_AND_ECHO_EVENTS
@NEEDS_CONTEXTVARS
@_TASK_CALLERS
@pytest.mark.parametrize("subgraphs", [False, True])
async def test_task_events_stream_under_the_graph_namespace_async(
nested: bool, subgraphs: bool
) -> None:
@task
async def chat_and_echo() -> None:
await _achat_and_echo({"messages": []})
@task
async def outer() -> None:
await chat_and_echo()
@entrypoint()
async def workflow(inputs: dict) -> None:
await (outer() if nested else chat_and_echo())
parts = workflow.astream(
{}, stream_mode=["messages", "tools"], subgraphs=subgraphs, version="v2"
)
assert [_summary(part) async for part in parts] == _CHAT_AND_ECHO_EVENTS
def test_task_events_stream_under_the_namespace_of_their_subgraph() -> None:
@task
def chat_and_echo() -> None:
_chat_and_echo({"messages": []})
def node(state: MessagesState) -> dict:
chat_and_echo().result()
return {}
sub = _one_node_graph(node)
parent = StateGraph(MessagesState).add_node("sub", sub).add_edge(START, "sub")
parts = parent.compile().stream(
{"messages": []},
stream_mode=["messages", "tools"],
subgraphs=True,
version="v2",
)
assert [_summary(part) for part in parts] == [
(("sub",), event) for _, event in _CHAT_AND_ECHO_EVENTS
]
@_CHILD_CONFIGS
def test_child_graph_streams_only_to_the_node_streaming_it(
child_config: dict | None,
) -> None:
child = _one_node_graph(_chat_and_echo)
received = []
def node(state: MessagesState) -> dict:
received.extend(
child.stream(
{"messages": []}, child_config, stream_mode=["messages", "tools"]
)
)
return {}
parts = _one_node_graph(node).stream(
{"messages": []},
{"configurable": {"thread_id": "parent"}},
stream_mode=["messages", "tools"],
)
assert list(parts) == []
assert len(received) == len(_CHAT_AND_ECHO_EVENTS)
@NEEDS_CONTEXTVARS
@_CHILD_CONFIGS
async def test_child_graph_streams_only_to_the_node_streaming_it_async(
child_config: dict | None,
) -> None:
child = _one_node_graph(_achat_and_echo)
received = []
async def node(state: MessagesState) -> dict:
stream = child.astream(
{"messages": []}, child_config, stream_mode=["messages", "tools"]
)
received.extend([part async for part in stream])
return {}
parts = _one_node_graph(node).astream(
{"messages": []},
{"configurable": {"thread_id": "parent"}},
stream_mode=["messages", "tools"],
)
assert [part async for part in parts] == []
assert len(received) == len(_CHAT_AND_ECHO_EVENTS)
@pytest.mark.parametrize(
"child_config",
[
{"configurable": {"thread_id": "child"}},
{"configurable": {"thread_id": "parent"}},
],
ids=["own_thread", "caller_thread"],
)
def test_child_graph_on_its_own_thread_skips_a_caller_streaming_subgraphs(
child_config: dict,
) -> None:
child = _one_node_graph(_chat_and_echo)
def node(state: MessagesState) -> dict:
child.invoke({"messages": []}, child_config)
return {}
parts = _one_node_graph(node).stream(
{"messages": []},
{"configurable": {"thread_id": "parent"}},
stream_mode=["messages", "tools"],
subgraphs=True,
)
assert list(parts) == []
def test_node_gets_the_events_of_every_subgraph_it_streams() -> None:
sub = _one_node_graph(_chat_and_echo)
counts = []
def node(state: MessagesState) -> dict:
for _ in range(2):
stream = sub.stream({"messages": []}, stream_mode=["messages", "tools"])
counts.append(len(list(stream)))
return {}
_one_node_graph(node).invoke({"messages": []})
assert counts == [len(_CHAT_AND_ECHO_EVENTS)] * 2
@NEEDS_CONTEXTVARS
async def test_node_gets_the_events_of_every_subgraph_it_streams_async() -> None:
sub = _one_node_graph(_achat_and_echo)
counts = []
async def node(state: MessagesState) -> dict:
for _ in range(2):
stream = sub.astream({"messages": []}, stream_mode=["messages", "tools"])
counts.append(len([part async for part in stream]))
return {}
await _one_node_graph(node).ainvoke({"messages": []})
assert counts == [len(_CHAT_AND_ECHO_EVENTS)] * 2
def _push(state: MessagesState) -> dict:
push_message(AIMessage(id="pushed", content="hi"), state_key=None)
return {}
def _namespaces(graph: CompiledStateGraph, subgraphs: bool) -> list[tuple[str, ...]]:
parts = graph.stream(
{"messages": []}, stream_mode="messages", subgraphs=subgraphs, version="v2"
)
return [_segment_names(part["ns"]) for part in parts]
def _streams_pushing_graph(received: list) -> Any:
pushing = _one_node_graph(_push)
def node(state: MessagesState) -> dict:
for message, _ in pushing.stream({"messages": []}, stream_mode="messages"):
received.append(message.id)
return {}
return node
def test_pushed_message_streams_under_the_namespace_of_its_graph() -> None:
pushing = _one_node_graph(_push)
parent = StateGraph(MessagesState).add_node("sub", pushing).add_edge(START, "sub")
assert _namespaces(pushing, subgraphs=True) == [()]
assert _namespaces(parent.compile(), subgraphs=False) == []
assert _namespaces(parent.compile(), subgraphs=True) == [("sub",)]
def test_pushed_message_streams_to_the_node_that_streams_its_graph() -> None:
received: list[str] = []
graph = _one_node_graph(_streams_pushing_graph(received))
assert _namespaces(graph, subgraphs=False) == []
assert received == ["pushed"]
def test_pushed_message_streams_to_every_stream_that_takes_it() -> None:
received: list[str] = []
graph = _one_node_graph(_streams_pushing_graph(received))
assert _namespaces(graph, subgraphs=True) == [("node",)]
assert received == ["pushed"]
+3 -3
View File
@@ -3419,11 +3419,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "83.0.0"
version = "84.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
sdist = { url = "https://files.pythonhosted.org/packages/73/eb/7e5f628ee2eb168fecbf8026de7856b2543d6d609cc85f086b9a91ac93d6/setuptools-84.1.0.tar.gz", hash = "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", size = 1171783, upload-time = "2026-10-19T20:11:43.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/18/02/b4bbb90f8f29e334898ff45c6d9b1fffaa6f378017052b3023a06262b5f7/setuptools-84.1.0-py3-none-any.whl", hash = "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9", size = 819380, upload-time = "2026-10-19T20:11:41.684Z" },
]
[[package]]
+105 -59
View File
@@ -14,9 +14,14 @@ import orjson
from langgraph_sdk._shared.utilities import (
_orjson_default,
_reconnect_delay,
_validate_reconnect_location,
)
from langgraph_sdk.errors import _araise_for_status_typed
from langgraph_sdk.errors import (
InternalServerError,
RateLimitError,
_araise_for_status_typed,
)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
@@ -146,7 +151,8 @@ class HttpClient:
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
"""Send a request that automatically reconnects to Location header."""
"""Send a request that reconnects to its Location header if the response is
cut off, with backoff between attempts."""
request_headers, content = await _aencode_json(json)
if headers:
request_headers.update(headers)
@@ -176,13 +182,27 @@ class HttpClient:
stacklevel=2,
)
await r.aclose()
return await self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
for attempt in range(reconnect_limit):
await asyncio.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
# A restart or a deploy looks like a refused connection, a
# dropped body, a 429 or a 5xx; other errors won't go away.
try:
return await self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=0,
)
except httpx.HTTPStatusError as err:
status = err.response.status_code
if attempt == reconnect_limit - 1 or (
status != 429 and status < 500
):
raise
except httpx.TransportError:
if attempt == reconnect_limit - 1:
raise
async def stream(
self,
@@ -225,70 +245,96 @@ class HttpClient:
}
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
if reconnect_path is not None and not any(
key.lower() == "last-event-id" and value
for key, value in current_headers.items()
):
warnings.warn(
"Reconnecting without a Last-Event-ID, so events sent while "
"disconnected are not replayed. Only runs created with "
"stream_resumable=True send the event IDs a reconnect resumes "
"from.",
stacklevel=2,
)
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
async with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
await _araise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_cause: httpx.HTTPError | None = None
try:
async with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
await _araise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
# parse SSE
decoder = SSEDecoder()
try:
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
if sse is not None:
# parse SSE
decoder = SSEDecoder(last_event_id=last_event_id)
try:
async for line in aiter_lines_raw(res):
sse = decoder.decode(line=cast("bytes", line).rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or decoder.has_data:
# Progress since the last drop earns a fresh
# set of reconnect attempts.
reconnect_attempts = 0
yield sse
except httpx.HTTPError as err:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
reconnect_cause = err
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
if sse.event or decoder.has_data:
# decoder.decode(b"") flushes the in-flight event and may
# return an empty placeholder when there is no pending
# message. Skip these no-op events so the stream doesn't
# emit a trailing blank item after reconnects.
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# decoder.decode(b"") flushes the in-flight event and may
# return an empty placeholder when there is no pending
# message. Skip these no-op events so the stream doesn't
# emit a trailing blank item after reconnects.
yield sse
if retry:
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
# A reconnect that can't open (connection refused, 429, 5xx or a
# non-SSE body) usually means the server is restarting or
# mid-deploy, so retry it like a drop. Before the server sends a
# Location there is nothing to reconnect to, so raise.
if reconnect_path is None:
raise
reconnect_cause = err
if reconnect_cause is not None:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
) from reconnect_cause
await asyncio.sleep(
_reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0)
)
continue
break
+8 -7
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypedDict, cast
@@ -25,7 +24,7 @@ from langchain_core.language_models.chat_model_stream import AsyncChatModelStrea
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._shared.utilities import _quote_path_param, _reconnect_delay
from langgraph_sdk.schema import LangSmithTracing, QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
@@ -1691,11 +1690,13 @@ class AsyncThreadStream:
async def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff and jitter for reconnect attempt `attempt`."""
base = self._shared_reconnect_backoff_base
cap = self._shared_reconnect_backoff_cap
delay = min(cap, base * (2**attempt))
jitter = random.uniform(0, delay * 0.25)
await asyncio.sleep(delay + jitter)
await asyncio.sleep(
_reconnect_delay(
attempt,
base=self._shared_reconnect_backoff_base,
cap=self._shared_reconnect_backoff_cap,
)
)
async def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a post-ready transport drop.
@@ -4,6 +4,7 @@ from __future__ import annotations
import functools
import os
import random
import re
from collections.abc import Mapping
from datetime import tzinfo
@@ -194,6 +195,15 @@ def _validate_reconnect_location(base_url: httpx.URL, location: str) -> str:
return location
def _reconnect_delay(attempt: int, *, base: float, cap: float) -> float:
"""Seconds to wait before reconnect attempt `attempt` (0-based).
Exponential backoff capped at `cap`, plus up to 25% jitter.
"""
delay = min(cap, base * (2**attempt))
return delay + random.uniform(0, delay * 0.25)
def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
+101 -56
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import sys
import time
import warnings
from collections.abc import Callable, Iterator, Mapping
from typing import Any, cast
@@ -13,9 +14,14 @@ import orjson
from langgraph_sdk._shared.utilities import (
_orjson_default,
_reconnect_delay,
_validate_reconnect_location,
)
from langgraph_sdk.errors import _raise_for_status_typed
from langgraph_sdk.errors import (
InternalServerError,
RateLimitError,
_raise_for_status_typed,
)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
from langgraph_sdk.sse import SSEDecoder, iter_lines_raw
@@ -146,7 +152,8 @@ class SyncHttpClient:
on_response: Callable[[httpx.Response], None] | None = None,
reconnect_limit: int = 5,
) -> Any:
"""Send a request that automatically reconnects to Location header."""
"""Send a request that reconnects to its Location header if the response is
cut off, with backoff between attempts."""
request_headers, content = _encode_json(json)
if headers:
request_headers.update(headers)
@@ -176,13 +183,27 @@ class SyncHttpClient:
stacklevel=2,
)
r.close()
return self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
for attempt in range(reconnect_limit):
time.sleep(_reconnect_delay(attempt, base=1.0, cap=5.0))
# A restart or a deploy looks like a refused connection, a
# dropped body, a 429 or a 5xx; other errors won't go away.
try:
return self.request_reconnect(
loc,
"GET",
headers=headers,
# don't pass on_response so it's only called once
reconnect_limit=0,
)
except httpx.HTTPStatusError as err:
status = err.response.status_code
if attempt == reconnect_limit - 1 or (
status != 429 and status < 500
):
raise
except httpx.TransportError:
if attempt == reconnect_limit - 1:
raise
def stream(
self,
@@ -227,67 +248,91 @@ class SyncHttpClient:
}
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
if reconnect_path is not None and not any(
key.lower() == "last-event-id" and value
for key, value in current_headers.items()
):
warnings.warn(
"Reconnecting without a Last-Event-ID, so events sent while "
"disconnected are not replayed. Only runs created with "
"stream_resumable=True send the event IDs a reconnect resumes "
"from.",
stacklevel=2,
)
current_method = method if reconnect_path is None else "GET"
current_content = content if reconnect_path is None else None
current_params = params if reconnect_path is None else None
retry = False
with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
_raise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_cause: httpx.HTTPError | None = None
try:
with self.client.stream(
current_method,
reconnect_path or path,
headers=current_headers,
content=current_content,
params=current_params,
) as res:
if reconnect_path is None and on_response:
on_response(res)
# check status
_raise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
raise httpx.TransportError(
"Expected response header Content-Type to contain 'text/event-stream', "
f"got {content_type!r}"
)
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
reconnect_location = res.headers.get("location")
if reconnect_location:
_validate_reconnect_location(
self.client.base_url, reconnect_location
)
reconnect_path = reconnect_location
decoder = SSEDecoder()
try:
for line in iter_lines_raw(res):
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
if sse is not None:
decoder = SSEDecoder(last_event_id=last_event_id)
try:
for line in iter_lines_raw(res):
sse = decoder.decode(cast(bytes, line).rstrip(b"\n"))
if sse is not None:
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or decoder.has_data:
# Progress since the last drop earns a fresh
# set of reconnect attempts.
reconnect_attempts = 0
yield sse
except httpx.HTTPError as err:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
reconnect_cause = err
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
if sse.event or decoder.has_data:
# See async stream implementation for rationale on
# skipping empty flush events.
yield sse
except httpx.HTTPError:
# httpx.TransportError inherits from HTTPError, so transient
# disconnects during streaming land here.
if reconnect_path is None:
raise
retry = True
else:
if sse := decoder.decode(b""):
if decoder.last_event_id is not None:
last_event_id = decoder.last_event_id
if sse.event or sse.data is not None:
# See async stream implementation for rationale on
# skipping empty flush events.
yield sse
if retry:
except (httpx.TransportError, RateLimitError, InternalServerError) as err:
# A reconnect that can't open (connection refused, 429, 5xx or a
# non-SSE body) usually means the server is restarting or
# mid-deploy, so retry it like a drop. Before the server sends a
# Location there is nothing to reconnect to, so raise.
if reconnect_path is None:
raise
reconnect_cause = err
if reconnect_cause is not None:
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
raise httpx.TransportError(
"Exceeded maximum SSE reconnection attempts"
)
) from reconnect_cause
time.sleep(_reconnect_delay(reconnect_attempts - 1, base=1.0, cap=5.0))
continue
break
+1 -1
View File
@@ -811,7 +811,7 @@ class _On:
def __call__(
self,
*,
resources: str | Sequence[str],
resources: str | Sequence[str] | None = None,
actions: str | Sequence[str] | None = None,
) -> Callable[[AHO], AHO]: ...
+33 -20
View File
@@ -30,18 +30,14 @@ class BytesLineDecoder:
# See https://docs.python.org/3/glossary.html#term-universal-newlines
NEWLINE_CHARS = b"\n\r"
# We always push a trailing `\r` into the next decode iteration.
if self.trailing_cr:
text = b"\r" + text
self.trailing_cr = False
if text.endswith(b"\r"):
self.trailing_cr = True
text = text[:-1]
if not text:
return []
if self.trailing_cr and text.startswith(b"\n"):
text = text[1:]
self.trailing_cr = text.endswith(b"\r")
if not text:
# NOTE: the edge case input of empty text doesn't occur in practice,
# because other httpx internals filter out this value
return [] # pragma: no cover
return []
trailing_newline = text[-1] in NEWLINE_CHARS
lines = cast(list[BytesLike], text.splitlines())
@@ -66,7 +62,7 @@ class BytesLineDecoder:
return lines
def flush(self) -> list[BytesLike]:
if not self.buffer and not self.trailing_cr:
if not self.buffer:
return []
lines: list[BytesLike] = [self.buffer]
@@ -76,18 +72,25 @@ class BytesLineDecoder:
class SSEDecoder:
def __init__(self) -> None:
def __init__(self, *, last_event_id: str | None = None) -> None:
self._event = ""
self._data = bytearray()
self._last_event_id: str | None = None
self._last_event_id = last_event_id
self._retry: int | None = None
self._at_stream_start = True
self._has_data = False
self._id_changed = False
@property
def has_data(self) -> bool:
"""Whether the last decoded block contained a data field."""
return self._has_data
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen.
`None` until the stream sends an `id` field, and `""` after an empty
`None` until an ID is supplied or received, and `""` after an empty
one, which resets the reconnect cursor.
"""
@@ -101,17 +104,26 @@ class SSEDecoder:
line = line.removeprefix(b"\xef\xbb\xbf")
if not line:
self._has_data = bool(self._data)
if (
not self._event
and not self._data
and self._last_event_id is None
and not self._id_changed
and self._retry is None
):
return None
body = self._data[:-1]
data = None
if body and not body.isspace():
try:
data = orjson.loads(body)
except orjson.JSONDecodeError:
data = orjson.loads(body.decode(errors="replace"))
sse = StreamPart(
event=self._event,
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
data=data, # ty: ignore[invalid-argument-type]
id=self._last_event_id or None,
)
@@ -119,6 +131,7 @@ class SSEDecoder:
self._event = ""
self._data = bytearray()
self._retry = None
self._id_changed = False
return sse
@@ -131,16 +144,16 @@ class SSEDecoder:
value = value[1:]
if fieldname == b"event":
self._event = value.decode()
self._event = value.decode(errors="replace")
elif fieldname == b"data":
if self._data:
self._data.extend(b"\n")
self._data.extend(value)
self._data.extend(b"\n")
elif fieldname == b"id":
if b"\0" in value:
pass
else:
self._last_event_id = value.decode()
self._last_event_id = value.decode(errors="replace")
self._id_changed = True
elif fieldname == b"retry":
with contextlib.suppress(TypeError, ValueError):
self._retry = int(value)
@@ -15,7 +15,6 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
from collections import OrderedDict
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field
@@ -23,6 +22,7 @@ from typing import Any
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._shared.utilities import _reconnect_delay
from langgraph_sdk.stream.transport import AsyncProtocolTransport, EventStreamHandle
# ---------------------------------------------------------------------------
@@ -275,11 +275,13 @@ class StreamController:
async def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff and jitter for reconnect attempt *attempt*."""
base = self._reconnect_backoff_base
cap = self._reconnect_backoff_cap
delay = min(cap, base * (2**attempt))
jitter = random.uniform(0, delay * 0.25)
await asyncio.sleep(delay + jitter)
await asyncio.sleep(
_reconnect_delay(
attempt,
base=self._reconnect_backoff_base,
cap=self._reconnect_backoff_cap,
)
)
async def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a transport drop.
@@ -4,7 +4,6 @@ from __future__ import annotations
import contextlib
import logging
import random
import threading
import time
from dataclasses import dataclass, field
@@ -13,6 +12,7 @@ from typing import Any
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._shared.utilities import _reconnect_delay
from langgraph_sdk.stream.errors import (
is_non_retryable_stream_error,
stream_error_frame,
@@ -282,11 +282,13 @@ class SyncStreamController:
def _reconnect_sleep(self, attempt: int) -> None:
"""Sleep with exponential backoff + jitter before a reconnect attempt."""
base = self._reconnect_backoff_base
cap = self._reconnect_backoff_cap
delay = min(cap, base * (2**attempt))
jitter = random.uniform(0, delay * 0.25)
time.sleep(delay + jitter)
time.sleep(
_reconnect_delay(
attempt,
base=self._reconnect_backoff_base,
cap=self._reconnect_backoff_cap,
)
)
def _reconnect_shared_stream(self) -> bool:
"""Attempt to reopen the shared stream after a transport drop.
+17
View File
@@ -0,0 +1,17 @@
import asyncio
import time
import pytest
@pytest.fixture
def no_sleep(monkeypatch):
"""Record backoff sleeps without actually waiting."""
sleeps: list[float] = []
async def fake_async_sleep(d):
sleeps.append(d)
monkeypatch.setattr(asyncio, "sleep", fake_async_sleep)
monkeypatch.setattr(time, "sleep", lambda d: sleeps.append(d))
return sleeps
+41
View File
@@ -1,8 +1,49 @@
import ast
import inspect
import pytest
from langgraph_sdk import Auth
@pytest.mark.parametrize(
"kwargs",
[{}, {"actions": "read"}, {"resources": None}],
)
def test_global_handler_factory_accepts_optional_resources(kwargs) -> None:
auth = Auth()
tree = ast.parse(inspect.getsource(type(auth.on)))
cls = tree.body[0]
assert isinstance(cls, ast.ClassDef)
factory_overload = next(
node
for node in cls.body
if isinstance(node, ast.FunctionDef)
and node.name == "__call__"
and node.decorator_list
and any(arg.arg == "resources" for arg in node.args.kwonlyargs)
)
resources = next(
arg for arg in factory_overload.args.kwonlyargs if arg.arg == "resources"
)
assert resources.annotation is not None
assert ast.unparse(resources.annotation) == "str | Sequence[str] | None"
default = factory_overload.args.kw_defaults[0]
assert isinstance(default, ast.Constant) and default.value is None
@auth.on(**kwargs)
async def handler(ctx, value):
del ctx, value
return None
if kwargs.get("actions"):
assert auth._handlers == {("*", "read"): [handler]}
assert auth._global_handlers == []
else:
assert auth._global_handlers == [handler]
assert auth._handlers == {}
def test_handler_multiple_resources_and_actions() -> None:
auth = Auth()
+424 -7
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Iterator, Sequence
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
from typing import Any
@@ -13,6 +13,7 @@ from langgraph_sdk._async.runs import _wrap_stream_v2
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
from langgraph_sdk.client import HttpClient, SyncHttpClient
from langgraph_sdk.errors import InternalServerError, NotFoundError
from langgraph_sdk.schema import (
CheckpointPayload,
CheckpointsStreamPart,
@@ -130,6 +131,110 @@ def test_sse_decoder_strips_a_leading_bom():
assert decoder.decode(b"") == StreamPart(event="values", data=1, id=None) # ty: ignore[invalid-argument-type]
def test_control_blocks_do_not_repeat_after_a_cursor_update():
decoder = SSEDecoder()
decoder.decode(b"id: cursor")
assert decoder.decode(b"") == StreamPart(event="", data=None, id="cursor") # ty: ignore[invalid-argument-type]
decoder.decode(b": heartbeat")
assert decoder.decode(b"") is None
assert decoder.decode(b"") is None
decoder.decode(b"id:")
assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) # ty: ignore[invalid-argument-type]
assert decoder.last_event_id == ""
assert decoder.decode(b"") is None
def test_cr_lines_are_emitted_without_waiting_for_another_chunk():
decoder = BytesLineDecoder()
assert decoder.decode(b"data: 1\r\r") == [b"data: 1", b""]
assert decoder.decode(b"\n") == []
assert decoder.decode(b"data: 2\r") == [b"data: 2"]
assert decoder.decode(b"") == []
assert decoder.decode(b"\n\r") == [b""]
assert decoder.flush() == []
def test_empty_data_lines_preserve_their_newlines():
decoder = SSEDecoder()
for line in (b"data", b"data: 1", b"data:"):
decoder.decode(line)
assert decoder._data == b"\n1\n\n"
assert decoder.decode(b"") == StreamPart(event="", data=1, id=None) # ty: ignore[invalid-argument-type]
decoder.decode(b"data:")
decoder.decode(b"data:")
assert decoder.decode(b"") == StreamPart(event="", data=None, id=None) # ty: ignore[invalid-argument-type]
assert decoder.has_data
assert decoder.decode(b"") is None
assert not decoder.has_data
def test_malformed_utf8_is_replaced_in_fields_and_json_strings():
decoder = SSEDecoder()
for line in (b"event: \xff", b"id: \xff", b'data: "\xff"'):
decoder.decode(line)
assert decoder.decode(b"") == StreamPart(event="\ufffd", data="\ufffd", id="\ufffd") # ty: ignore[invalid-argument-type]
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_null_and_empty_data_are_not_dropped(asynchronous: bool):
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=b"data: null\n\ndata:\n\n",
)
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
parts = [part async for part in HttpClient(client).stream("/stream", "GET")]
else:
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
parts = list(SyncHttpClient(client).stream("/stream", "GET"))
assert parts == [StreamPart(event="", data=None), StreamPart(event="", data=None)] # ty: ignore[invalid-argument-type]
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_cursor_is_inherited_after_reconnect(asynchronous: bool):
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if len(requests) == 1:
stream_cls = AsyncListByteStream if asynchronous else ListByteStream
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream", "Location": "/reconnect"},
stream=stream_cls(
[b"id: cursor\nevent: values\ndata: 1\n\n"],
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
assert (b"Last-Event-ID", b"cursor") in request.headers.raw
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=b"event: values\ndata: 2\n\n",
)
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
parts = [part async for part in HttpClient(client).stream("/stream", "GET")]
else:
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
parts = list(SyncHttpClient(client).stream("/stream", "GET"))
assert [part.id for part in parts] == ["cursor", "cursor"]
# --- HTTP client streaming ---
@@ -176,6 +281,7 @@ def test_sync_http_client_stream_flushes_trailing_event():
assert parts == [StreamPart(event="foo", data={"bar": 1})]
@pytest.mark.usefixtures("no_sleep")
def test_sync_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
@@ -241,6 +347,7 @@ def test_sync_http_client_stream_recovers_after_disconnect():
@pytest.mark.asyncio
@pytest.mark.usefixtures("no_sleep")
async def test_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
@@ -375,28 +482,33 @@ _CALLER_CURSORS = pytest.mark.parametrize(
@_EMPTY_IDS
@_CALLER_CURSORS
@pytest.mark.usefixtures("no_sleep")
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id(
empty_id: bytes, headers: dict[str, str] | None
):
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
with pytest.warns(UserWarning, match="Last-Event-ID"):
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
assert "last-event-id" not in reconnect.headers
@_EMPTY_IDS
@_CALLER_CURSORS
@pytest.mark.usefixtures("no_sleep")
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id(
empty_id: bytes, headers: dict[str, str] | None
):
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
with pytest.warns(UserWarning, match="Last-Event-ID"):
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
assert "last-event-id" not in reconnect.headers
@pytest.mark.usefixtures("no_sleep")
def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
@@ -405,6 +517,7 @@ def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
assert reconnect.headers.get_list("last-event-id") == ["1"]
@pytest.mark.usefixtures("no_sleep")
async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
@@ -413,12 +526,14 @@ async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
assert reconnect.headers.get_list("last-event-id") == ["1"]
@pytest.mark.usefixtures("no_sleep")
def test_sync_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
reconnect = _sync_reconnect([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"})
assert reconnect.headers.get_list("last-event-id") == ["5"]
@pytest.mark.usefixtures("no_sleep")
async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
reconnect = await _async_reconnect(
[b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}
@@ -427,6 +542,308 @@ async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id
assert reconnect.headers.get_list("last-event-id") == ["5"]
# A reconnect that sends no Last-Event-ID can't replay what was sent while it was
# disconnected, so it warns.
_RECONNECT_CURSORS = pytest.mark.parametrize(
("chunks", "headers", "warns"),
[
([b"event: values\ndata: 1\n\n"], None, True),
([b"id: 1\nevent: values\ndata: 1\n\n"], None, False),
([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}, False),
],
ids=["no-cursor", "stream-cursor", "caller-cursor"],
)
@_RECONNECT_CURSORS
@pytest.mark.usefixtures("no_sleep")
def test_sync_reconnect_warns_only_without_a_cursor(
chunks: list[bytes],
headers: dict[str, str] | None,
warns: bool,
recwarn: pytest.WarningsRecorder,
):
_sync_reconnect(chunks, headers)
assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
@_RECONNECT_CURSORS
@pytest.mark.usefixtures("no_sleep")
async def test_async_reconnect_warns_only_without_a_cursor(
chunks: list[bytes],
headers: dict[str, str] | None,
warns: bool,
recwarn: pytest.WarningsRecorder,
):
await _async_reconnect(chunks, headers)
assert any("Last-Event-ID" in str(w.message) for w in recwarn) is warns
# --- reconnects survive a server restart or a deploy ---
SSE = {"Content-Type": "text/event-stream"}
DROP = httpx.RemoteProtocolError("incomplete chunked read")
def _event(i: int) -> bytes:
return f"id: {i}\nevent: values\ndata: {i}\n\n".encode()
def _scripted(steps: list[Callable[[httpx.Request], httpx.Response]]):
"""Handler that plays one step per request and records each request."""
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
if len(seen) > len(steps):
raise AssertionError("unexpected request")
return steps[len(seen) - 1](request)
return handler, seen
def _ok(stream_cls, chunks, *, drop=False, location=None):
headers = dict(SSE)
if location:
headers["Location"] = location
def step(_request):
return httpx.Response(
200, headers=headers, stream=stream_cls(chunks, DROP if drop else None)
)
return step
def _status(code):
return lambda request: httpx.Response(
code, json={"detail": "upstream"}, request=request
)
def _connect_error(request):
raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
def _not_sse(request):
return httpx.Response(
200, headers={"Content-Type": "text/html"}, text="<html>", request=request
)
def _transient_steps(stream_cls, failure):
"""POST drops after event 1; first reconnect fails; second succeeds."""
return [
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
failure,
_ok(stream_cls, [_event(2), b"event: end\ndata: null\n\n"]),
]
def _long_stream_steps(stream_cls, drops):
"""POST + `drops` reconnects, each delivering one event then dropping."""
steps = [_ok(stream_cls, [_event(0)], drop=True, location="/reconnect")]
for i in range(1, drops):
steps.append(_ok(stream_cls, [_event(i)], drop=True))
steps.append(_ok(stream_cls, [_event(drops), b"event: end\ndata: null\n\n"]))
return steps
def _run_sync(steps):
handler, seen = _scripted(steps)
with httpx.Client(
transport=httpx.MockTransport(handler), base_url="https://example.com"
) as client:
parts = list(SyncHttpClient(client).stream("/stream", "POST", json={}))
return parts, seen
async def _run_async(steps):
sync_handler, seen = _scripted(steps)
async def handler(request):
return sync_handler(request)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="https://example.com"
) as client:
parts = [p async for p in HttpClient(client).stream("/stream", "POST", json={})]
return parts, seen
FAILURES = {
"429": _status(429),
"502": _status(502),
"503": _status(503),
"connect_error": _connect_error,
"not_sse": _not_sse,
}
# A reconnect that hits a 429 / 502 / 503 / connect error / non-SSE body, then
# succeeds, should recover.
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("failure", FAILURES.values(), ids=FAILURES.keys())
def test_sync_reconnect_recovers_from_transient_failure(failure):
parts, seen = _run_sync(_transient_steps(ListByteStream, failure))
assert [p.event for p in parts] == ["values", "values", "end"]
assert len(seen) == 3
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("failure", FAILURES.values(), ids=FAILURES.keys())
async def test_async_reconnect_recovers_from_transient_failure(failure):
parts, seen = await _run_async(_transient_steps(AsyncListByteStream, failure))
assert [p.event for p in parts] == ["values", "values", "end"]
assert len(seen) == 3
# Any other 4xx on a reconnect won't go away by retrying, so it raises.
def _client_error_steps(stream_cls):
return [
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
_status(404),
]
@pytest.mark.usefixtures("no_sleep")
def test_sync_reconnect_does_not_retry_a_client_error():
with pytest.raises(NotFoundError):
_run_sync(_client_error_steps(ListByteStream))
@pytest.mark.usefixtures("no_sleep")
async def test_async_reconnect_does_not_retry_a_client_error():
with pytest.raises(NotFoundError):
await _run_async(_client_error_steps(AsyncListByteStream))
# The first request isn't a reconnect, so it raises even when it names a Location.
def _first_request_503(request):
return httpx.Response(
503,
headers={"Location": "/reconnect"},
json={"detail": "upstream"},
request=request,
)
@pytest.mark.usefixtures("no_sleep")
def test_sync_first_request_failure_is_not_retried():
with pytest.raises(InternalServerError):
_run_sync([_first_request_503])
@pytest.mark.usefixtures("no_sleep")
async def test_async_first_request_failure_is_not_retried():
with pytest.raises(InternalServerError):
await _run_async([_first_request_503])
# A server that stays down gets five reconnects, backing off 1, 2, 4, 5 and 5s
# (each plus up to 25% jitter), so about 20s, before the stream gives up. The
# error it gives up with is caused by the last failure, so callers see why.
GIVE_UP = "Exceeded maximum SSE reconnection attempts"
def _server_down_steps(stream_cls):
return [
_ok(stream_cls, [_event(1)], drop=True, location="/reconnect"),
*[_status(502)] * 5,
]
def _assert_backed_off_for_about_20s(sleeps: list[float]) -> None:
assert len(sleeps) == 5
for slept, delay in zip(sleeps, [1, 2, 4, 5, 5], strict=True):
assert delay <= slept <= delay * 1.25
def test_sync_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
_run_sync(_server_down_steps(ListByteStream))
assert isinstance(exc_info.value.__cause__, InternalServerError)
_assert_backed_off_for_about_20s(no_sleep)
async def test_async_reconnect_gives_up_after_five_backed_off_attempts(no_sleep):
with pytest.raises(httpx.TransportError, match=GIVE_UP) as exc_info:
await _run_async(_server_down_steps(AsyncListByteStream))
assert isinstance(exc_info.value.__cause__, InternalServerError)
_assert_backed_off_for_about_20s(no_sleep)
# A long stream that drops 6 times, each reconnect succeeding and making
# progress, should not run out of attempts.
@pytest.mark.usefixtures("no_sleep")
def test_sync_long_stream_survives_many_spaced_out_drops():
parts, _ = _run_sync(_long_stream_steps(ListByteStream, drops=6))
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
@pytest.mark.usefixtures("no_sleep")
async def test_async_long_stream_survives_many_spaced_out_drops():
parts, _ = await _run_async(_long_stream_steps(AsyncListByteStream, drops=6))
assert [p.data for p in parts] == [0, 1, 2, 3, 4, 5, 6, None]
# Each reconnect resumes after the newest event the stream got, so a reconnect
# loop on a resumable run replays only what it missed. A failed reconnect keeps
# the cursor for the next one.
RECONNECT_LOOPS = {
"progress_between_drops": (
lambda stream_cls: _long_stream_steps(stream_cls, drops=3),
[None, "0", "1", "2"],
),
"failed_reconnect": (
lambda stream_cls: _transient_steps(stream_cls, _status(502)),
[None, "1", "1"],
),
}
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize(
("steps", "cursors"), RECONNECT_LOOPS.values(), ids=RECONNECT_LOOPS.keys()
)
def test_sync_reconnect_loop_resumes_after_the_newest_event(steps, cursors):
_, seen = _run_sync(steps(ListByteStream))
assert [r.headers.get("last-event-id") for r in seen] == cursors
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize(
("steps", "cursors"), RECONNECT_LOOPS.values(), ids=RECONNECT_LOOPS.keys()
)
async def test_async_reconnect_loop_resumes_after_the_newest_event(steps, cursors):
_, seen = await _run_async(steps(AsyncListByteStream))
assert [r.headers.get("last-event-id") for r in seen] == cursors
# Reconnects should back off rather than hammer the server.
def test_sync_reconnect_backs_off(no_sleep):
_run_sync(_long_stream_steps(ListByteStream, drops=2))
assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
async def test_async_reconnect_backs_off(no_sleep):
await _run_async(_long_stream_steps(AsyncListByteStream, drops=2))
assert len(no_sleep) == 2 and all(d > 0 for d in no_sleep)
# --- _sse_to_v2_dict conversion ---
+98 -3
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from typing import Any
import httpx
import pytest
@@ -24,7 +25,16 @@ class _BodyDropsMidRead(httpx.SyncByteStream, httpx.AsyncByteStream):
raise httpx.ReadError("connection dropped")
def _wait_handler(requests: list[httpx.Request]):
def _done(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"done": True})
def _wait_handler(
requests: list[httpx.Request],
reconnects: Sequence[Callable[[httpx.Request], httpx.Response]] = (_done,),
):
"""POST drops mid-body; the reconnects answer from `reconnects` in order."""
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.method == "POST":
@@ -33,7 +43,7 @@ def _wait_handler(requests: list[httpx.Request]):
headers={"Location": "/threads/t1/runs/r1/join"},
stream=_BodyDropsMidRead(),
)
return httpx.Response(200, json={"done": True})
return reconnects[len(requests) - 2](request)
return handler
@@ -46,6 +56,7 @@ def _assert_bodyless_get(request: httpx.Request) -> None:
assert request.read() == b""
@pytest.mark.usefixtures("no_sleep")
def test_sync_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
@@ -58,6 +69,7 @@ def test_sync_wait_reconnects_with_a_bodyless_get():
_assert_bodyless_get(requests[1])
@pytest.mark.usefixtures("no_sleep")
async def test_async_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
@@ -72,6 +84,89 @@ async def test_async_wait_reconnects_with_a_bodyless_get():
_assert_bodyless_get(requests[1])
def _connect_error(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("[Errno 61] Connection refused", request=request)
async def _wait(
handler: Callable[[httpx.Request], httpx.Response], *, asynchronous: bool
) -> Any:
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
return await RunsClient(HttpClient(client)).wait("t1", "agent", input={})
with httpx.Client(transport=transport, base_url="https://example.com") as client:
return SyncRunsClient(SyncHttpClient(client)).wait("t1", "agent", input={})
# What a server restart or a deploy looks like to the reconnect GET: each one is
# retried after a backoff.
RECONNECT_FAILURES = {
"429": lambda request: httpx.Response(429, request=request),
"503": lambda request: httpx.Response(503, request=request),
"connect_error": _connect_error,
"body_drops": lambda _request: httpx.Response(
200,
headers={"Location": "/threads/t1/runs/r1/join"},
stream=_BodyDropsMidRead(),
),
}
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize(
"failure", RECONNECT_FAILURES.values(), ids=RECONNECT_FAILURES.keys()
)
async def test_wait_retries_a_failed_reconnect_after_a_backoff(
failure, asynchronous, no_sleep
):
requests: list[httpx.Request] = []
with pytest.warns(UserWarning, match="attempting reconnect"):
result = await _wait(
_wait_handler(requests, [failure, _done]), asynchronous=asynchronous
)
assert result == {"done": True}
assert len(requests) == 3
assert len(no_sleep) == 2 and min(no_sleep) >= 1, no_sleep
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_wait_raises_after_five_failed_reconnects(asynchronous):
requests: list[httpx.Request] = []
with (
pytest.warns(UserWarning, match="attempting reconnect"),
pytest.raises(httpx.HTTPStatusError, match="503"),
):
await _wait(
_wait_handler(requests, [RECONNECT_FAILURES["503"]] * 5),
asynchronous=asynchronous,
)
assert len(requests) == 6
@pytest.mark.usefixtures("no_sleep")
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_wait_does_not_retry_a_client_error(asynchronous):
requests: list[httpx.Request] = []
with (
pytest.warns(UserWarning, match="attempting reconnect"),
pytest.raises(httpx.HTTPStatusError, match="404"),
):
await _wait(
_wait_handler(
requests, [lambda request: httpx.Response(404, request=request)]
),
asynchronous=asynchronous,
)
assert len(requests) == 2
def test_sync_wait_raises_the_run_error():
requests: list[httpx.Request] = []