Compare commits

..
Author SHA1 Message Date
Nick HollonandGitHub 13f2ecc84b release(sdk-py): 0.4.2 (#7955) 2026-06-01 13:49:09 -04:00
Nick HollonandGitHub af5dab5b77 fix(sdk-py): percent-encode thread_id in v3 stream transport default paths (#7954)
## Problem

Fixes #7953.

The v3 SSE and WebSocket stream transports build their default paths by
interpolating `thread_id` directly into the URL:

```python
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_url   = stream_path   or f"/threads/{thread_id}/stream/events"
```

This skips the `_quote_path_param` escaping that the rest of the SDK
adopted in #7893. A `thread_id` containing reserved characters or
dot-segments is then normalized by the HTTP/WebSocket stack before
transmission. For example, `thread_id = "../assistants/abc"`:

| | before |
|---|---|
| constructed | `/threads/../assistants/abc/commands` |
| wire path | `/assistants/abc/commands` |

So the value stops being one opaque identifier under
`/threads/{thread_id}/...` and silently hits a different resource.

## Fix

Reuse the existing `_quote_path_param` helper for the **default** paths
in all four transports (`http`, `sync_http`, `ws`, `sync_ws`). Explicit
`commands_path` / `stream_path` overrides are left untouched, so callers
that pass their own paths opt out of encoding as before.

`_quote_path_param("../assistants/abc")` → `..%2Fassistants%2Fabc`,
which the HTTP/WS stack no longer collapses.

## Tests

Adds `tests/streaming/test_transport_path_encoding.py` covering all four
transports:
- default `_commands_url` / `_stream_url` / `_stream_path` are
percent-encoded,
- the actual SSE wire path (async + sync) stays under `/threads/`,
- the built WebSocket URL (async + sync) stays under `/threads/`,
- explicit path overrides are left untouched.

`make format`, `make lint`, and `make test` all pass in `libs/sdk-py`
(491 passed).
2026-06-01 13:20:32 -04:00
Nick HollonandGitHub 312c6d0ac1 feat(langgraph): wire RemoteGraph.interleave to sdk-py interleave_projections (#7938) 2026-06-01 12:42:42 -04:00
Nick HollonandGitHub f1dc4577e2 release(sdk-py): 0.4.1 (#7944) 2026-06-01 11:19:53 -04:00
Nick HollonandGitHub 1fcb768182 feat(sdk-py): extract stream decoders and add interleave_projections (#7935)
## Summary

Refactors the four (now five) sdk-py streaming projections into
reusable, transport-agnostic `Decoder` classes and adds a new
`interleave_projections(channels)` method to `AsyncThreadStream` and
`SyncThreadStream` that drives multiple decoders from one shared
subscription, yielding `(channel_name, item)` tuples in arrival order
(the SDK analog of local `GraphRunStream.interleave`).

- **New `langgraph_sdk/stream/decoders.py`**: pure `feed(event) ->
Iterable[item]` state machines — `ValuesDecoder`, `MessagesDecoder`,
`ToolCallsDecoder`, `SubgraphsDecoder`, `ExtensionsDecoder` — behind a
`Decoder` Protocol. No subscription/queue/thread access.
- **Projection migration (async + sync)**: the five existing projections
now delegate their per-event logic to the decoders. Behavior-preserving
— the existing test suite is the regression net. Thread-coupled side
effects (active-stream registration, terminal-error-on-close, root-inbox
forwarding) stay in the projection wrappers; sync messages/tool_calls
keep their pre-dispatch contract (handle/stream resolved on yield) via a
FIFO-head buffer.
- **`interleave_projections`**: flat-namespace channel list (built-ins +
extension names), `tool_calls`↔`tools` wire mapping, subgraphs fed every
event, extensions keyed by bare name.

### Notable
- Fixes a latent sync bug surfaced by the refactor: two tool calls /
messages whose events interleave previously dropped the second; both now
surface. Locked in with a regression test.
- `Decoder.feed` takes `Mapping[str, Any]` (read-only), so the Protocol
is load-bearing in both stream files.

### Deferred (not in this PR)
- Wiring `RemoteGraph._RemoteGraphRunStream.interleave` to
`interleave_projections` (gated on #7927).
- Migrating the handle-scoped projections (`_Handle*Projection`) to the
decoders — hence the small, verified-identical helper duplication
between `decoders.py` and `_async/stream.py`.
- `interleave_projections` handles aren't registered for thread-close
cleanup (additive follow-up).

## Test Plan
- [x] `make test` in `libs/sdk-py` — 464 passed, 0 failures
- [x] `make format` / `make lint` (ruff + ty) clean
- [x] Per-decoder unit tests in `tests/streaming/test_decoders.py`
- [x] `interleave_projections` tests (single-channel, multi-channel
arrival order, builtin+extension mix, tool_calls public-name, subgraphs
discovery) async + sync
- [x] Existing projection suites pass unchanged (regression net for the
migration)
2026-05-29 17:14:14 -04:00
Nick HollonandGitHub 68fa011fc9 feat(langgraph): add v3 streaming support to RemoteGraph (#7927)
## Summary

- Adds `stream_events(version="v3")` and `astream_events(version="v3")`
to `RemoteGraph`, matching the local `CompiledStateGraph` surface and
unblocking polymorphic v3 streaming over `Graph | RemoteGraph`.
- Implementation is a thin adapter
(`libs/langgraph/langgraph/pregel/_remote_run_stream.py`) that wraps the
v3 SDK's `AsyncThreadStream` / `SyncThreadStream` and duck-types
`GraphRunStream` / `AsyncGraphRunStream`. No coupling to local v3 mux
internals.
- `v1` / `v2` paths unchanged. `astream_events(version='v1'|'v2')` still
raises `NotImplementedError` (separate gap).

### Scope decisions baked into this PR

- Unsupported v3 kwargs hard-reject at dispatch with
`NotImplementedError`: `control`, `transformers`, `interrupt_before`,
`interrupt_after`, and any unknown `**kwargs`. Server / SDK don't plumb
these through v3 yet; easy to lift later.
- Sync `interleave()` raises `NotImplementedError` pointing callers at
`astream_events`. Real sync interleave would need drainer threads;
deferred since most sync RemoteGraph callers just iterate raw events.
- Async `interleave()` is best-effort ordering (client receive order),
documented as a divergence from local v3's monotonic stamp ordering.
- Adapter `interrupted` / `interrupts` properties are **non-blocking**
snapshots of the SDK's current state. This differs from local
`(Async)GraphRunStream.interrupted`, which pump-drives the run to
terminal before returning. Callers needing a wait-for-interrupt pattern
should drain a projection (e.g., `interleave('values')`) until the SDK's
paused sentinel fires. Documented in the adapter docstrings.

### Audit of impact

Existing RemoteGraph callers in this org all use the v2 `.stream()` /
`.astream()` path (deepagents production wrapper, langgraph-api test
graphs, langgraph-supervisor TS type guard). **Zero callers** use
`stream_events` / `astream_events` on RemoteGraph today, so the new v3
methods are net-new surface — no risk of breaking existing consumers.

### Out of scope (follow-ups)

- Bumping `libs/langgraph/pyproject.toml`'s `langgraph-sdk` constraint
from `<0.4.0` to `<0.5.0`. Deferred until 0.4.0 publishes to PyPI; dev
resolution unaffected via the editable workspace dep.
- Real `astream_events(version='v1'|'v2')` implementation.
- Server-side plumbing for `control` / `interrupt_before` /
`interrupt_after` on v3 runs.
- Sync `interleave()` via drainer threads.


## Test plan

- [x] \`make test\` in \`libs/langgraph/\`: 1874 passed, 4 skipped (43
new in \`test_remote_graph_v3.py\`)
- [x] \`make lint\` in \`libs/langgraph/\`: ruff + mypy clean
- [x] \`pytest -m integration
tests/integration/test_remote_graph_v3.py\` in \`libs/sdk-py/\` against
the docker stack: 4/4 passed in 1.35s
- [x] Manual smoke: \`RemoteGraph('tools_agent',
url='http://localhost:2024').astream_events(..., version='v3')\`
end-to-end against the v3 integration api
- [x] Existing RemoteGraph v2 tests untouched (31 passed, 3 skipped with
docker up)
- [x] Will need rebase after \`langgraph-sdk 0.4.0\` lands on PyPI and
the version constraint is bumped in a separate PR
2026-05-29 17:13:43 -04:00
Nick HollonandGitHub ac3f5b007b feat(langgraph): name tool-dispatched subagents via lc_agent_name (#7928) 2026-05-29 16:46:45 -04:00
syachamaneni-lcandGitHub a9b0a05fb5 chore(langgraph): Track ADK/other library usage when deploying using cli (#7939)
<!-- Replace everything above this line with a 1-2 sentence description
of your change. Keep the "Fixes #xx" keyword and update the issue
number. -->
- Add a property to revisions table metadata column for Google ADK
version
- This helps us track the deployments that use Google ADK
- Similar to this PR:
https://github.com/langchain-ai/langchainplus/pull/22087

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

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

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

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

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

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

2. PR description:

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

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

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

4. How did you verify your code works?

Additional guidelines:

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

## Social handles (optional)
<!-- If you'd like a shoutout on release, add your socials below -->
Twitter: @
LinkedIn: https://linkedin.com/in/
2026-05-29 13:46:10 -07:00
Nick HollonandGitHub b7fd3cf9c4 fix(langgraph): rename ProtocolEvent.eventId to event_id to match the wire field (#7942) 2026-05-29 14:46:59 -04:00
Nick HollonandGitHub 64bd4d1f13 fix(langgraph): merge instead of overwrite in ensure_config for callbacks, tags, metadata, configurable (#7926) 2026-05-29 09:40:22 -04:00
syachamaneni-lcandGitHub f25a0f4f4c fix(langgraph): [LSD-1507] Distinguish between user cancelled and other cancellations (#7920)
- Distinguish between Node cancellations and other cancellations
- Use python 3.11+ feature where `cancelling() == 0` when it is the node
cancelling
- Bubble up the node cancellation example so the client can take care of
it instead of silently failing without reporting it.

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

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

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

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

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

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

2. PR description:

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

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

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

4. How did you verify your code works?
- Existing unit tests pass
- New unit tests added
- Used langgraph deployment to make sure the feature is working as
expected.

Additional guidelines:

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

## Social handles (optional)
<!-- If you'd like a shoutout on release, add your socials below -->
Twitter: @
LinkedIn: https://linkedin.com/in/
2026-05-28 14:34:18 -07:00
Nick HollonandGitHub ea4aa79a60 fix(sdk-py): make tools_agent fake model stateless (#7930) 2026-05-28 16:42:45 -04:00
Nick HollonandGitHub c7792608e3 release(sdk-py): 0.4.0 (#7923)
## Summary

Bumps `langgraph-sdk` `0.3.15` → `0.4.0`.

Minor bump to reflect the v3 streaming public API that landed
since 0.3.15:

- `client.threads.stream(...)` — new thread-centric streaming entry
point (async + sync)
- SSE and WebSocket transports (`ProtocolSseTransport`,
`ProtocolWebSocketTransport`) with reconnect handling and stream
selection
- Shared stream subscriptions, lifecycle / interrupts state, output /
values projections, messages / tool-call projections, scoped subgraph
handles, thread stream helpers
2026-05-28 10:08:47 -04:00
Josh RogersandGitHub 7282301720 release(cli): 0.4.27 (#7925)
Bumping the CLI to 0.4.27.
2026-05-28 10:03:26 -04:00
42 changed files with 4960 additions and 642 deletions
+2
View File
@@ -50,6 +50,8 @@ jobs:
- '**/uv.lock'
sdk_py:
- 'libs/sdk-py/**'
- 'libs/langgraph/langgraph/pregel/remote.py'
- 'libs/langgraph/langgraph/pregel/_remote_run_stream.py'
lint:
needs: changes
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.26"
__version__ = "0.4.27"
+4 -24
View File
@@ -66,34 +66,14 @@ def _get_pip_cleanup_lines(
to_uninstall: tuple[str] | None,
pip_installer: Literal["uv", "pip"],
) -> str:
commands = ["# -- Validate installed dependencies are internally consistent --"]
if pip_installer == "uv":
commands.append(
"""RUN uv pip check --system || ( \
echo "Dependency resolution check failed. One or more installed packages are incompatible."; \
echo "Pin compatible versions in your dependencies and try again."; \
exit 1 \
)"""
)
elif pip_installer == "pip":
commands.append(
"""RUN python -m pip check || ( \
echo "Dependency resolution check failed. One or more installed packages are incompatible."; \
echo "Pin compatible versions in your dependencies and try again."; \
exit 1 \
)"""
)
commands.append(
"""# -- End dependency validation --
# -- Ensure user deps didn't inadvertently overwrite langgraph-api
commands = [
f"""# -- Ensure user deps didn't inadvertently overwrite langgraph-api
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
RUN PYTHONDONTWRITEBYTECODE=1 """
+ install_cmd
+ """ --no-cache-dir --no-deps -e /api
RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
# -- Removing build deps from the final image ~<:===~~~ --"""
)
]
if to_uninstall:
for pack in to_uninstall:
if pack not in _BUILD_TOOLS:
@@ -0,0 +1,141 @@
"""Detection of tracked Python packages in a local LangGraph project.
Mirrors host-backend's `host.models.dependency_tracking` so that CLI-based
deploys report the same `tracked_packages` revision metadata that
GitHub-based deploys do. The host backend strictly validates each entry
against `<package-name>:<version>` with package-name in `TRACKED_PACKAGES`,
so the detection rules here must match exactly.
"""
from __future__ import annotations
import pathlib
import re
# Single source of truth for which packages the host backend cares about.
# Keep in sync with host-backend/host/models/tracked_packages.py.
TRACKED_PACKAGES: tuple[str, ...] = ("google-adk",)
_MAX_READ_BYTES = 5 * 1024 * 1024
_PACKAGES_ALT = "|".join(re.escape(p) for p in TRACKED_PACKAGES)
_DEPS_RE = re.compile(
rf"(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})"
r"(?:\[[^\]]*\])?"
r"\s*((?:(?:==|>=|<=|~=|!=|>|<)\s*[\w.*]+\s*,?\s*)+)"
)
_UV_LOCK_RE = re.compile(
rf'name\s*=\s*"({_PACKAGES_ALT})"\s*\n\s*version\s*=\s*"([^"]+)"'
)
_BARE_RE = re.compile(rf'(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})(?:\[[^\]]*\])?\s*[,"\'\n]')
_EXTRAS_BRACKET_RE = re.compile(r"\[([a-zA-Z0-9_.\- ,\t]+)\]")
def _appears_in_extras(content: str, pkg: str) -> bool:
for m in _EXTRAS_BRACKET_RE.finditer(content):
for token in m.group(1).split(","):
if token.strip() == pkg:
return True
return False
def _read_text(path: pathlib.Path) -> str | None:
try:
if not path.is_file():
return None
with open(path, "rb") as f:
data = f.read(_MAX_READ_BYTES + 1)
except OSError:
return None
if len(data) > _MAX_READ_BYTES:
data = data[:_MAX_READ_BYTES]
return data.decode("utf-8", errors="replace")
def _find_version_for(
pkg: str,
lock_content: str | None,
pyproject_content: str | None,
requirements_content: str | None,
) -> str | None:
if lock_content is not None:
for m in _UV_LOCK_RE.finditer(lock_content):
if m.group(1) == pkg:
return m.group(2)
for content in (pyproject_content, requirements_content):
if content is None:
continue
for m in _DEPS_RE.finditer(content):
if m.group(1) == pkg:
return m.group(2).strip().rstrip(",")
for m in _BARE_RE.finditer(content):
if m.group(1) == pkg:
return "unknown"
if _appears_in_extras(content, pkg):
return "unknown"
return None
def _resolved_dep_base(
project_root: pathlib.Path, dep_path: str
) -> pathlib.Path | None:
"""Return the resolved dep directory if it stays inside the project root."""
try:
candidate = (project_root / dep_path).resolve()
except (OSError, RuntimeError):
return None
try:
candidate.relative_to(project_root)
except ValueError:
return None
return candidate
def find_tracked_packages(
config: pathlib.Path,
config_json: dict,
) -> list[str]:
"""Return every tracked package found in deps as `<name>:<version>` entries.
`config` is the absolute path to `langgraph.json`; dep paths in
`config_json["dependencies"]` are resolved relative to its parent.
Detection precedence per package: uv.lock resolved > pyproject.toml /
requirements.txt specifier > bare reference > extras bracket (last
two recorded as "unknown"). Output is ordered by `TRACKED_PACKAGES`.
"""
try:
project_root = config.parent.resolve()
except (OSError, RuntimeError):
return []
dep_paths = config_json.get("dependencies") or ["."]
found: dict[str, str] = {}
for dep_path in dep_paths:
if all(pkg in found for pkg in TRACKED_PACKAGES):
break
if not isinstance(dep_path, str):
continue
base = _resolved_dep_base(project_root, dep_path)
if base is None or not base.is_dir():
continue
lock_content = _read_text(base / "uv.lock")
pyproject_content = _read_text(base / "pyproject.toml")
requirements_content = _read_text(base / "requirements.txt")
for pkg in TRACKED_PACKAGES:
if pkg in found:
continue
version = _find_version_for(
pkg, lock_content, pyproject_content, requirements_content
)
if version is not None:
found[pkg] = version
return [f"{pkg}:{found[pkg]}" for pkg in TRACKED_PACKAGES if pkg in found]
+19 -1
View File
@@ -20,6 +20,7 @@ from dotenv import dotenv_values, set_key
import langgraph_cli.config
from langgraph_cli.analytics import log_command
from langgraph_cli.constants import DEFAULT_CONFIG
from langgraph_cli.dependency_tracking import find_tracked_packages
from langgraph_cli.docker import build_docker_image, can_build_locally
from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
@@ -902,6 +903,7 @@ def _run_local_build(
build_command: str | None,
docker_build_args: Sequence[str],
secrets: list[dict[str, str]],
tracked_packages: list[str] | None,
) -> BuildResult:
"""Build locally with Docker, push to registry, update deployment."""
# Use buildx to cross-compile for amd64 when running on a non-x86_64 host
@@ -1060,7 +1062,10 @@ def _run_local_build(
# -- Step: Update deployment --
_log_deploy_step(step, f"Updating deployment {deployment_id}")
updated = client.update_deployment(
deployment_id, resolved_image, secrets=secrets
deployment_id,
resolved_image,
secrets=secrets,
tracked_packages=tracked_packages,
)
return BuildResult(
@@ -1083,6 +1088,7 @@ def _run_remote_build(
install_command: str | None,
build_command: str | None,
secrets: list[dict[str, str]],
tracked_packages: list[str] | None,
) -> BuildResult:
"""Upload source tarball and trigger a remote build."""
from langgraph_cli.archive import create_archive
@@ -1113,6 +1119,7 @@ def _run_remote_build(
secrets=secrets,
install_command=install_command,
build_command=build_command,
tracked_packages=tracked_packages,
)
log_offset: str | None = None
@@ -1597,6 +1604,15 @@ def _deploy_cmd(
if not deployment_id:
raise click.ClickException("Failed to determine deployment ID")
# Scan local sources for tracked packages so the new revision carries
# the same metadata GitHub-backed deploys produce. Failures must never
# block a deploy.
try:
tracked_packages = find_tracked_packages(config, config_json) or None
except Exception as exc:
em.warn(f"Skipped tracked-package scan: {exc}")
tracked_packages = None
# -- 3. Build (divergent path) --
if use_remote_build:
build_result = _run_remote_build(
@@ -1609,6 +1625,7 @@ def _deploy_cmd(
install_command=install_command,
build_command=build_command,
secrets=secrets,
tracked_packages=tracked_packages,
)
else:
build_result = _run_local_build(
@@ -1628,6 +1645,7 @@ def _deploy_cmd(
build_command=build_command,
docker_build_args=docker_build_args,
secrets=secrets,
tracked_packages=tracked_packages,
)
# -- 4. Shared wait + result --
+6
View File
@@ -122,11 +122,14 @@ class HostBackendClient:
deployment_id: str,
image_uri: str,
secrets: list[dict[str, str]] | None = None,
tracked_packages: list[str] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"revision_source": "internal_docker",
"source_revision_config": {"image_uri": image_uri},
}
if tracked_packages:
payload["tracked_packages"] = tracked_packages
if secrets is not None:
payload["secrets"] = secrets
return self._request(
@@ -143,6 +146,7 @@ class HostBackendClient:
secrets: list[dict[str, str]] | None = None,
install_command: str | None = None,
build_command: str | None = None,
tracked_packages: list[str] | None = None,
) -> dict[str, Any]:
"""Trigger a remote build revision with the uploaded tarball."""
payload: dict[str, Any] = {
@@ -152,6 +156,8 @@ class HostBackendClient:
"langgraph_config_path": config_path,
},
}
if tracked_packages:
payload["tracked_packages"] = tracked_packages
source_config: dict[str, Any] = {}
if install_command is not None:
+4 -38
View File
@@ -1330,8 +1330,6 @@ def test_config_to_docker_pip_installer():
PATH_TO_CONFIG, config_auto, base_image="langchain/langgraph-api:0.2.47"
)
assert "uv pip install --system " in docker_auto
assert "RUN uv pip check --system" in docker_auto
assert "python -m pip check" not in docker_auto
assert "rm /usr/bin/uv /usr/bin/uvx" in docker_auto
# Test explicit pip setting
@@ -1340,8 +1338,6 @@ def test_config_to_docker_pip_installer():
PATH_TO_CONFIG, config_pip, base_image="langchain/langgraph-api:0.2.47"
)
assert "uv pip install --system " not in docker_pip
assert "RUN uv pip check --system" not in docker_pip
assert "RUN python -m pip check" in docker_pip
assert "pip install" in docker_pip
assert "rm /usr/bin/uv" not in docker_pip
@@ -1351,8 +1347,6 @@ def test_config_to_docker_pip_installer():
PATH_TO_CONFIG, config_uv, base_image="langchain/langgraph-api:0.2.47"
)
assert "uv pip install --system " in docker_uv
assert "RUN uv pip check --system" in docker_uv
assert "python -m pip check" not in docker_uv
assert "rm /usr/bin/uv /usr/bin/uvx" in docker_uv
# Test auto behavior with older image (should use pip)
@@ -1363,8 +1357,6 @@ def test_config_to_docker_pip_installer():
PATH_TO_CONFIG, config_auto_old, base_image="langchain/langgraph-api:0.2.46"
)
assert "uv pip install --system " not in docker_auto_old
assert "RUN uv pip check --system" not in docker_auto_old
assert "RUN python -m pip check" in docker_auto_old
assert "pip install" in docker_auto_old
assert "rm /usr/bin/uv" not in docker_auto_old
@@ -1374,32 +1366,6 @@ def test_config_to_docker_pip_installer():
PATH_TO_CONFIG, config_default, base_image="langchain/langgraph-api:0.2.47"
)
assert "uv pip install --system " in docker_default
assert "RUN uv pip check --system" in docker_default
assert "python -m pip check" not in docker_default
def test_get_pip_cleanup_lines_selects_check_command_by_installer():
cleanup_uv = _get_pip_cleanup_lines(
install_cmd="uv pip install --system",
to_uninstall=None,
pip_installer="uv",
)
assert "RUN uv pip check --system" in cleanup_uv
assert "python -m pip check" not in cleanup_uv
assert cleanup_uv.index("RUN uv pip check --system") < cleanup_uv.index(
"RUN mkdir -p /api/langgraph_api"
)
cleanup_pip = _get_pip_cleanup_lines(
install_cmd="pip install",
to_uninstall=None,
pip_installer="pip",
)
assert "RUN python -m pip check" in cleanup_pip
assert "uv pip check --system" not in cleanup_pip
assert cleanup_pip.index("RUN python -m pip check") < cleanup_pip.index(
"RUN mkdir -p /api/langgraph_api"
)
def test_config_to_docker_uv_lock():
@@ -2524,7 +2490,7 @@ def test_config_to_compose_simple_config():
def test_config_to_compose_env_vars():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = f""" OPENAI_API_KEY: "key"
pull_policy: build
build:
context: .
@@ -2607,7 +2573,7 @@ def test_config_to_compose_env_file():
def test_config_to_compose_watch():
graphs = {"agent": "./agent.py:graph"}
expected_compose_stdin = f"""\
pull_policy: build
build:
context: .
@@ -2633,7 +2599,7 @@ def test_config_to_compose_watch():
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
WORKDIR /deps/outer-unit_tests/unit_tests
develop:
watch:
- path: test_config.json
@@ -2680,7 +2646,7 @@ def test_config_to_compose_end_to_end():
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), " ")}
WORKDIR /deps/outer-unit_tests/unit_tests
develop:
watch:
- path: test_config.json
@@ -0,0 +1,134 @@
import pathlib
import pytest
from langgraph_cli.dependency_tracking import (
TRACKED_PACKAGES,
find_tracked_packages,
)
def _write_project(
tmp_path: pathlib.Path,
*,
dep_subdir: str = ".",
uv_lock: str | None = None,
pyproject: str | None = None,
requirements: str | None = None,
dependencies: list[str] | None = None,
) -> tuple[pathlib.Path, dict]:
project_root = tmp_path
dep_dir = (project_root / dep_subdir).resolve()
dep_dir.mkdir(parents=True, exist_ok=True)
if uv_lock is not None:
(dep_dir / "uv.lock").write_text(uv_lock)
if pyproject is not None:
(dep_dir / "pyproject.toml").write_text(pyproject)
if requirements is not None:
(dep_dir / "requirements.txt").write_text(requirements)
config = project_root / "langgraph.json"
config.write_text("{}")
config_json = {"dependencies": dependencies or [dep_subdir]}
return config, config_json
def test_uv_lock_resolved_version_preferred(tmp_path: pathlib.Path) -> None:
config, config_json = _write_project(
tmp_path,
uv_lock='name = "google-adk"\nversion = "1.2.3"\n',
pyproject='dependencies = ["google-adk>=0.5"]',
)
assert find_tracked_packages(config, config_json) == ["google-adk:1.2.3"]
def test_pyproject_specifier_used_when_no_lock(tmp_path: pathlib.Path) -> None:
config, config_json = _write_project(
tmp_path,
pyproject='dependencies = ["google-adk>=0.5,<2"]',
)
assert find_tracked_packages(config, config_json) == ["google-adk:>=0.5,<2"]
def test_requirements_txt_specifier(tmp_path: pathlib.Path) -> None:
config, config_json = _write_project(
tmp_path,
requirements="google-adk==1.0.0\n",
)
assert find_tracked_packages(config, config_json) == ["google-adk:==1.0.0"]
def test_bare_reference_records_unknown(tmp_path: pathlib.Path) -> None:
config, config_json = _write_project(
tmp_path,
requirements="google-adk\nother-pkg==1.0\n",
)
assert find_tracked_packages(config, config_json) == ["google-adk:unknown"]
def test_extras_bracket_records_unknown(tmp_path: pathlib.Path) -> None:
config, config_json = _write_project(
tmp_path,
pyproject='dependencies = ["deployments-wrap-sdk[google-adk]>=0.0.1"]',
)
assert find_tracked_packages(config, config_json) == ["google-adk:unknown"]
def test_no_match_returns_empty(tmp_path: pathlib.Path) -> None:
config, config_json = _write_project(
tmp_path,
pyproject='dependencies = ["langgraph>=0.2"]',
)
assert find_tracked_packages(config, config_json) == []
def test_traversal_dep_path_is_skipped(tmp_path: pathlib.Path) -> None:
outside = tmp_path.parent / "outside-project"
outside.mkdir(exist_ok=True)
(outside / "uv.lock").write_text('name = "google-adk"\nversion = "9.9.9"\n')
project_root = tmp_path / "project"
project_root.mkdir()
config = project_root / "langgraph.json"
config.write_text("{}")
config_json = {"dependencies": ["../outside-project"]}
assert find_tracked_packages(config, config_json) == []
def test_dep_paths_scanned_in_order(tmp_path: pathlib.Path) -> None:
project_root = tmp_path
(project_root / "first").mkdir()
(project_root / "second").mkdir()
(project_root / "second" / "uv.lock").write_text(
'name = "google-adk"\nversion = "2.0.0"\n'
)
config = project_root / "langgraph.json"
config.write_text("{}")
config_json = {"dependencies": ["first", "second"]}
assert find_tracked_packages(config, config_json) == ["google-adk:2.0.0"]
def test_non_string_dep_entry_ignored(tmp_path: pathlib.Path) -> None:
project_root = tmp_path
config = project_root / "langgraph.json"
config.write_text("{}")
config_json = {"dependencies": [123, None]}
assert find_tracked_packages(config, config_json) == []
def test_oversized_file_is_truncated_not_raised(tmp_path: pathlib.Path) -> None:
project_root = tmp_path
config = project_root / "langgraph.json"
config.write_text("{}")
# 6 MB of irrelevant content followed by the tracked-package marker —
# the read cap drops the marker, so nothing should be found.
padded = ("x" * (6 * 1024 * 1024)) + '\nname = "google-adk"\nversion = "1.0.0"\n'
(project_root / "uv.lock").write_text(padded)
assert find_tracked_packages(config, {"dependencies": ["."]}) == []
@pytest.mark.parametrize("pkg", TRACKED_PACKAGES)
def test_every_tracked_package_is_detectable(tmp_path: pathlib.Path, pkg: str) -> None:
config, config_json = _write_project(
tmp_path,
uv_lock=f'name = "{pkg}"\nversion = "1.0.0"\n',
)
assert find_tracked_packages(config, config_json) == [f"{pkg}:1.0.0"]
@@ -1,3 +1,5 @@
import json
import httpx
import pytest
@@ -176,6 +178,69 @@ def test_update_deployment_no_secrets(client):
assert result == {"ok": True}
def _capturing_client(captured: dict) -> HostBackendClient:
def handler(req: httpx.Request) -> httpx.Response:
captured["body"] = req.read()
return httpx.Response(200, json={"ok": True})
c = HostBackendClient("https://api.example.com", "key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "key", "Accept": "application/json"},
timeout=30,
)
return c
def test_update_deployment_forwards_tracked_packages():
captured: dict = {}
c = _capturing_client(captured)
c.update_deployment(
"dep-123",
"image:latest",
tracked_packages=["google-adk:1.0.0"],
)
body = json.loads(captured["body"])
assert body["tracked_packages"] == ["google-adk:1.0.0"]
assert "tracked_packages" not in body["source_revision_config"]
def test_update_deployment_omits_tracked_packages_when_absent():
captured: dict = {}
c = _capturing_client(captured)
c.update_deployment("dep-123", "image:latest")
body = json.loads(captured["body"])
assert "tracked_packages" not in body
def test_update_deployment_internal_source_forwards_tracked_packages():
captured: dict = {}
c = _capturing_client(captured)
c.update_deployment_internal_source(
"dep-123",
source_tarball_path="path/to/tarball",
config_path="langgraph.json",
tracked_packages=["google-adk:>=0.5"],
)
body = json.loads(captured["body"])
assert body["tracked_packages"] == ["google-adk:>=0.5"]
assert body["source_revision_config"]["source_tarball_path"] == "path/to/tarball"
assert "tracked_packages" not in body["source_revision_config"]
def test_update_deployment_internal_source_omits_tracked_packages_when_absent():
captured: dict = {}
c = _capturing_client(captured)
c.update_deployment_internal_source(
"dep-123",
source_tarball_path="path/to/tarball",
config_path="langgraph.json",
)
body = json.loads(captured["body"])
assert "tracked_packages" not in body
def test_list_revisions(client):
result = client.list_revisions("dep-123", limit=5)
assert result == {"ok": True}
+79 -29
View File
@@ -79,6 +79,34 @@ def patch_checkpoint_map(
return config
def _merge_callbacks(base: Callbacks, new: Callbacks) -> Callbacks:
"""Merge two callbacks values (None / list / BaseCallbackManager).
Six cases total (3 base types x 2 non-None new types).
"""
if new is None:
return base
if base is None:
return new.copy() if isinstance(new, (list, BaseCallbackManager)) else new
if isinstance(new, list):
if isinstance(base, list):
return base + new
if isinstance(base, BaseCallbackManager):
mngr = base.copy()
for cb in new:
mngr.add_handler(cb, inherit=True)
return mngr
elif isinstance(new, BaseCallbackManager):
if isinstance(base, list):
mngr = new.copy()
for cb in base:
mngr.add_handler(cb, inherit=True)
return mngr
if isinstance(base, BaseCallbackManager):
return base.merge(new)
raise NotImplementedError(f"Unsupported callback types: {type(base)}, {type(new)}")
def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
"""Merge multiple configs into one.
@@ -113,34 +141,9 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
else:
base[key] = value
elif key == "callbacks":
base_callbacks = base.get("callbacks")
# callbacks can be either None, list[handler] or manager
# so merging two callbacks values has 6 cases
if isinstance(value, list):
if base_callbacks is None:
base["callbacks"] = value.copy()
elif isinstance(base_callbacks, list):
base["callbacks"] = base_callbacks + value
else:
# base_callbacks is a manager
mngr = base_callbacks.copy()
for callback in value:
mngr.add_handler(callback, inherit=True)
base["callbacks"] = mngr
elif isinstance(value, BaseCallbackManager):
# value is a manager
if base_callbacks is None:
base["callbacks"] = value.copy()
elif isinstance(base_callbacks, list):
mngr = value.copy()
for callback in base_callbacks:
mngr.add_handler(callback, inherit=True)
base["callbacks"] = mngr
else:
# base_callbacks is also a manager
base["callbacks"] = base_callbacks.merge(value)
else:
raise NotImplementedError
base["callbacks"] = _merge_callbacks(
base.get("callbacks"), cast(Callbacks, value)
)
elif key == "recursion_limit":
if config["recursion_limit"] != DEFAULT_RECURSION_LIMIT:
base["recursion_limit"] = config["recursion_limit"]
@@ -309,7 +312,40 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
for k, v in config.items():
if _is_not_empty(v) and k in CONFIG_KEYS:
if k == CONF:
empty[k] = cast(dict, v).copy()
# Shallow-merge configurable dicts across configs so values
# bound via with_config(...) (e.g. ls_agent_type) are
# preserved when later configs (e.g. invoke-time) only
# specify a subset of keys like thread_id.
existing = empty.get(k)
empty[k] = (
{**cast(dict, existing), **cast(dict, v)}
if existing
else cast(dict, v).copy()
)
elif k == "callbacks":
empty["callbacks"] = _merge_callbacks(
empty.get("callbacks"), cast(Callbacks, v)
)
elif k == "metadata":
# Shallow-merge metadata dicts across configs so values
# bound via with_config(...) (e.g. user_id) are preserved
# when later configs supply other metadata keys.
existing = empty.get("metadata")
empty["metadata"] = (
{**cast(dict, existing), **cast(dict, v)}
if existing
else cast(dict, v).copy()
)
elif k == "tags":
# Concatenate tags across configs so values bound via
# with_config(...) are preserved when later configs
# supply additional tags. Matches merge_configs.
existing_tags: list[str] | None = empty.get("tags")
empty["tags"] = (
[*existing_tags, *cast(list, v)]
if existing_tags
else list(cast(list, v))
)
else:
empty[k] = v # type: ignore[literal-required]
for k, v in config.items():
@@ -366,3 +402,17 @@ _PROPAGATE_TO_METADATA = frozenset(
"graph_id",
)
)
def filter_to_user_tags(tags: Sequence[str] | None) -> list[str] | None:
"""Drop langgraph's internal `seq:step:*` bookkeeping tags.
`seq:step:N` tags are added internally to mark sequence steps; everything
else (user-supplied tags and any other framework tags) is kept. Returns the
surviving tags, or `None` if none remain. Shared by the `messages` and
`tasks` stream handlers so both surface the same tag set on their metadata.
"""
if not tags:
return None
filtered = [t for t in tags if not t.startswith("seq:step")]
return filtered or None
+23
View File
@@ -21,6 +21,7 @@ __all__ = (
"InvalidUpdateError",
"GraphBubbleUp",
"GraphInterrupt",
"NodeCancelledError",
"NodeError",
"NodeInterrupt",
"NodeTimeoutError",
@@ -164,6 +165,28 @@ class NodeError:
"""Exception raised by the failed node."""
class NodeCancelledError(Exception):
"""Raised when a node body raises ``asyncio.CancelledError`` itself.
``asyncio.CancelledError`` is a ``BaseException`` and the pregel runner
treats cancelled task futures as silent tear-down (e.g. when it stops
sibling tasks after a peer fails). That is the correct behaviour for
*framework-initiated* cancellation, but a user node that raises
``asyncio.CancelledError`` from its own body should surface as a node
failure, the same way any other exception would.
The retry layer converts user-raised ``asyncio.CancelledError`` into this
type so it flows through the normal error path and the run reports as
``error`` instead of silently succeeding.
"""
node: str
def __init__(self, node: str, message: str | None = None) -> None:
super().__init__(message or f"Node {node!r} raised asyncio.CancelledError")
self.node = node
class NodeTimeoutError(Exception):
"""Raised when a node invocation exceeds one of its configured timeouts.
+3 -3
View File
@@ -15,6 +15,7 @@ from langchain_core.messages.utils import convert_to_messages
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
from pydantic import BaseModel
from langgraph._internal._config import filter_to_user_tags
from langgraph._internal._constants import NS_SEP
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
from langgraph.pregel.protocol import StreamChunk
@@ -143,9 +144,8 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
]
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
return
if tags:
if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
metadata["tags"] = filtered_tags
if (filtered_tags := filter_to_user_tags(tags)) is not None:
metadata["tags"] = filtered_tags
self.metadata[run_id] = (ns, metadata)
def on_llm_new_token(
@@ -0,0 +1,374 @@
# libs/langgraph/langgraph/pregel/_remote_run_stream.py
from __future__ import annotations
import logging
import sys
from collections.abc import AsyncIterator, Iterator, Mapping
from types import TracebackType
from typing import Any, cast
from langchain_core.runnables import RunnableConfig
from langgraph_sdk._async.stream import AsyncThreadStream
from langgraph_sdk._sync.stream import SyncThreadStream
from langgraph_sdk.client import LangGraphClient, SyncLangGraphClient
from langgraph_sdk.stream.decoders import DataDecoder
from langgraph.types import Command
logger = logging.getLogger(__name__)
def _translate_command_input(input: Any) -> Any:
"""Translate a local `Command` into the v3 wire `input`, else passthrough.
The v3 server decides start-vs-resume from thread state (an interrupted
run or pending interrupts) and, on resume, wraps the whole `input` as
`{"resume": input}` itself. So a resume `Command` must surface its raw
`resume` value as the wire `input` (not the serialized dataclass, which
the server would double-wrap). The v3 `run.start` path has no `goto` /
`update` channel, so those are rejected.
`langgraph_sdk` is upstream of `langgraph`, so this `Command`-aware
marshalling lives here on the adapter (langgraph) side of the boundary.
"""
if isinstance(input, Command):
if input.goto or input.update:
raise NotImplementedError(
"RemoteGraph v3 streaming supports `Command(resume=...)` only; "
"`goto` / `update` are not supported by the v3 `run.start` path."
)
return input.resume
return input
class _ChannelProjection:
"""Decoded projection for a wire channel the SDK doesn't type natively.
Subscribes to `channel` and decodes each event's `params["data"]` through the
SDK's `DataDecoder` — the same decoder the SDK's own plain-payload projections
(`values` / `updates` / `checkpoints` / `tasks`) use, which yields the item
shape that local's `UpdatesTransformer` / `CheckpointsTransformer` /
`TasksTransformer` / `CustomTransformer` push, so iterating this matches the
corresponding local projection. Iterate with `for` against a sync stream and
`async for` against an async stream (matching the underlying SDK). Opening
the subscription requires the stream to be entered (`with` / `async with`).
"""
def __init__(self, sdk: AsyncThreadStream | SyncThreadStream, channel: str) -> None:
self._sdk = sdk
self._channel = channel
def __iter__(self) -> Iterator[Any]:
# Sync lane: the sync adapter's SDK returns a sync iterator here.
decoder = DataDecoder(self._channel)
events = cast(Iterator[Any], self._sdk.subscribe([self._channel]))
for event in events:
yield from decoder.feed(event)
def __aiter__(self) -> AsyncIterator[Any]:
return self._aiter()
async def _aiter(self) -> AsyncIterator[Any]:
# Async lane: the async adapter's SDK returns an async iterator here.
decoder = DataDecoder(self._channel)
events = cast(AsyncIterator[Any], self._sdk.subscribe([self._channel]))
async for event in events:
for item in decoder.feed(event):
yield item
class _ProjectionRegistry(Mapping[str, Any]):
"""Read-only name -> projection registry mirroring local `GraphRunStream.extensions`.
Resolution follows the langchain-protocol wire channels, and every entry
yields the same decoded item shape local does (`params.data`):
- `values` / `messages` / `tool_calls` / `subgraphs` resolve to the SDK's
decoded typed projections. `tool_calls` is the `tools` channel — tool
*execution* events, distinct from the tool-call *inputs* inside `messages`.
- `updates` / `checkpoints` / `tasks` / `custom` have no typed SDK
projection, so they resolve to a `_ChannelProjection` that subscribes to
the channel and yields `params.data` — matching the local transformer
output for those channels.
- any other name is a specific custom-extension channel
(`thread.extensions[name]`, i.e. `custom:<name>`).
`lifecycle` is intentionally absent: local derives a status payload from it
rather than yielding `params.data`, and the SDK consumes it as control-plane
(driving `output` / `interrupted`), so its shape can't be matched — it
remains reachable via the raw `events` iterator. `debug` is absent too: it
is not a v3 wire channel.
"""
# Channels the SDK decodes into typed projections.
_TYPED = ("values", "messages", "tool_calls", "subgraphs")
# Wire channels with no typed SDK projection — decoded here to match local.
_DECODED = ("updates", "checkpoints", "tasks", "custom")
_NATIVE = _TYPED + _DECODED
def __init__(self, sdk: AsyncThreadStream | SyncThreadStream) -> None:
self._sdk = sdk
def __getitem__(self, name: str) -> Any:
if name in self._TYPED:
return getattr(self._sdk, name)
if name in self._DECODED:
return _ChannelProjection(self._sdk, name)
return self._sdk.extensions[name]
def __iter__(self) -> Iterator[str]:
return iter(self._NATIVE)
def __len__(self) -> int:
return len(self._NATIVE)
class _RemoteGraphRunStream:
"""Sync adapter: SyncThreadStream -> GraphRunStream surface."""
def __init__(
self,
*,
sync_client: SyncLangGraphClient,
sdk_thread: SyncThreadStream,
input: Any,
config: RunnableConfig | None,
metadata: dict[str, Any] | None,
) -> None:
self._client = sync_client
self._sdk = sdk_thread
self._start_kwargs: dict[str, Any] = {
"input": _translate_command_input(input),
"config": config,
"metadata": metadata,
}
self._run_id: str | None = None
self._closed = False
self._events_iter: Iterator[Any] | None = None
def __enter__(self) -> _RemoteGraphRunStream:
if self._closed:
raise RuntimeError("_RemoteGraphRunStream already closed")
self._sdk.__enter__()
try:
result = self._sdk.run.start(**self._start_kwargs)
except BaseException:
self._sdk.__exit__(*sys.exc_info())
raise
self._run_id = result["run_id"]
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
if self._closed:
return
self._closed = True
self._sdk.__exit__(exc_type, exc, tb)
@property
def output(self) -> Any:
return self._sdk.output
@property
def interrupted(self) -> bool:
"""Whether the remote run is currently paused at an interrupt.
Reads the SDK's current value without blocking. This differs from
local `GraphRunStream.interrupted`, which drives the run to terminal
before returning the flag. Sync callers needing a wait-for-interrupt
pattern should switch to the async API and drain a projection.
"""
return self._sdk.interrupted
@property
def interrupts(self) -> list[Any]:
"""Current outstanding interrupt payloads (non-blocking snapshot)."""
return list(self._sdk.interrupts)
@property
def values(self) -> Any:
"""Live state-snapshot projection (mirrors local `run.values`)."""
return self._sdk.values
@property
def messages(self) -> Any:
"""Live message-stream projection (mirrors local `run.messages`)."""
return self._sdk.messages
@property
def subgraphs(self) -> Any:
"""Subgraph-handle projection (mirrors local `run.subgraphs`)."""
return self._sdk.subgraphs
@property
def tool_calls(self) -> Any:
"""Tool-execution projection (the `tools` channel).
These are tool *execution* events (started / output / finished),
distinct from the tool-call *inputs* carried inside `messages`.
"""
return self._sdk.tool_calls
@property
def extensions(self) -> Mapping[str, Any]:
"""Name -> projection registry (mirrors local `run.extensions`)."""
return _ProjectionRegistry(self._sdk)
def abort(self) -> None:
if self._closed:
return
self._closed = True
if self._run_id is not None:
try:
self._client.runs.cancel(self._sdk.thread_id, self._run_id, wait=False)
except Exception:
logger.debug("abort: runs.cancel failed", exc_info=True)
try:
self._sdk.close()
except Exception:
logger.debug("abort: sdk.close failed", exc_info=True)
def __iter__(self) -> Iterator[Any]:
if self._events_iter is None:
self._events_iter = iter(self._sdk.events)
return self._events_iter
def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
yield from self._sdk.interleave_projections(list(names))
class _AsyncRemoteGraphRunStream:
"""Async adapter: AsyncThreadStream -> AsyncGraphRunStream surface."""
def __init__(
self,
*,
client: LangGraphClient,
sdk_thread: AsyncThreadStream,
input: Any,
config: RunnableConfig | None,
metadata: dict[str, Any] | None,
) -> None:
self._client = client
self._sdk = sdk_thread
self._start_kwargs: dict[str, Any] = {
"input": _translate_command_input(input),
"config": config,
"metadata": metadata,
}
self._run_id: str | None = None
self._closed = False
self._events_aiter: AsyncIterator[Any] | None = None
async def __aenter__(self) -> _AsyncRemoteGraphRunStream:
if self._closed:
raise RuntimeError("_AsyncRemoteGraphRunStream already closed")
await self._sdk.__aenter__()
try:
result = await self._sdk.run.start(**self._start_kwargs)
except BaseException:
await self._sdk.__aexit__(*sys.exc_info())
raise
self._run_id = result["run_id"]
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
if self._closed:
return
self._closed = True
await self._sdk.__aexit__(exc_type, exc, tb)
async def output(self) -> Any:
"""Drive the remote run to completion and return the final state.
Awaits the SDK's terminal-state awaitable, matching local
`AsyncGraphRunStream.output()` (a method, not a property, so
`run.output` without `await` fails at type-check time rather than
silently yielding a coroutine).
"""
return await self._sdk.output
async def interrupted(self) -> bool:
"""Whether the remote run is currently paused at an interrupt.
Reads the SDK's current value without blocking. This differs from
local `AsyncGraphRunStream.interrupted()`, which drives the run to
terminal before returning the flag. Callers that need a
wait-for-interrupt pattern should drain a projection (e.g.,
`async for snap in stream._sdk.values`) until the SDK's paused
sentinel fires, then call this method.
"""
return self._sdk.interrupted
async def interrupts(self) -> list[Any]:
"""Current outstanding interrupt payloads.
Non-blocking; reads the SDK's current snapshot. See `interrupted`
for the divergence from local v3 semantics.
"""
return list(self._sdk.interrupts)
@property
def values(self) -> Any:
"""Live state-snapshot projection (mirrors local `run.values`)."""
return self._sdk.values
@property
def messages(self) -> Any:
"""Live message-stream projection (mirrors local `run.messages`)."""
return self._sdk.messages
@property
def subgraphs(self) -> Any:
"""Subgraph-handle projection (mirrors local `run.subgraphs`)."""
return self._sdk.subgraphs
@property
def tool_calls(self) -> Any:
"""Tool-execution projection (the `tools` channel).
These are tool *execution* events (started / output / finished),
distinct from the tool-call *inputs* carried inside `messages`.
"""
return self._sdk.tool_calls
@property
def extensions(self) -> Mapping[str, Any]:
"""Name -> projection registry (mirrors local `run.extensions`)."""
return _ProjectionRegistry(self._sdk)
async def abort(self) -> None:
if self._closed:
return
self._closed = True
if self._run_id is not None:
try:
await self._client.runs.cancel(
self._sdk.thread_id, self._run_id, wait=False
)
except Exception:
logger.debug("abort: runs.cancel failed", exc_info=True)
try:
await self._sdk.close()
except Exception:
logger.debug("abort: sdk.close failed", exc_info=True)
def __aiter__(self) -> AsyncIterator[Any]:
if self._events_aiter is None:
self._events_aiter = self._sdk.events.__aiter__()
return self._events_aiter
# Note: deliberately no `interleave()` on the async adapter. Local
# `AsyncGraphRunStream` doesn't have one either (async callers compose
# with `asyncio.gather` / `asyncio.as_completed`). The sync adapter
# provides `interleave()` because sync callers have no comparable
# primitive for iterating multiple iterators concurrently.
+57 -1
View File
@@ -37,13 +37,23 @@ from langgraph._internal._constants import (
)
from langgraph._internal._runnable import create_task_in_config_context
from langgraph._internal._timeout import sync_timeout_unsupported
from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
from langgraph.errors import (
GraphBubbleUp,
NodeCancelledError,
NodeTimeoutError,
ParentCommand,
)
from langgraph.pregel.protocol import StreamProtocol
from langgraph.runtime import ExecutionInfo, Runtime
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
logger = logging.getLogger(__name__)
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
# `asyncio.Task.cancelling()` was added in Python 3.11. It reports the number of
# pending cancel requests on the task: ``0`` means no external code asked us to
# cancel — so a ``CancelledError`` observed here was raised by the task body
# itself (the user's node) rather than by pregel cancelling sibling tasks.
SUPPORTS_TASK_CANCELLING = sys.version_info >= (3, 11)
def _timeout_secs(value: float | timedelta) -> float:
@@ -302,6 +312,28 @@ class _IdleProgressCallbackHandler(BaseCallbackHandler):
on_custom_event = _touch
def _is_user_raised_cancelled() -> bool:
"""Return True if the in-flight ``CancelledError`` came from the task body.
Pregel cancels sibling tasks via ``task.cancel()`` when a peer fails, which
increments ``asyncio.Task.cancelling()`` on the target before the cancel
actually fires. A user node that calls ``raise asyncio.CancelledError()``
from inside its own body raises while ``cancelling() == 0``, which is the
signal we use to convert the exception into a regular
:class:`NodeCancelledError`.
Returns ``False`` when we can't tell (``cancelling()`` unavailable, or no
current task — neither should happen in practice from ``arun_with_retry``)
so framework-initiated cancellation continues to propagate unchanged.
"""
if not SUPPORTS_TASK_CANCELLING:
return False
current = asyncio.current_task()
if current is None:
return False
return current.cancelling() == 0
def _drain_cancelled(task: asyncio.Task[Any]) -> None:
# Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
with suppress(asyncio.CancelledError):
@@ -600,6 +632,12 @@ def run_with_retry(
except GraphBubbleUp:
# if interrupted, end
raise
except asyncio.CancelledError as exc:
# A sync node has no asyncio context, so any ``CancelledError`` that
# reaches here was raised by the node body itself. Surface it as a
# regular exception so the pregel runner panics the run instead of
# treating the task as a silent tear-down (LSD-1507).
raise NodeCancelledError(task.name) from exc
except Exception as exc:
if SUPPORTS_EXC_NOTES:
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
@@ -736,6 +774,24 @@ async def arun_with_retry(
# if interrupted, end
_finish_timed_attempt(config, attempt_ctx)
raise
except asyncio.CancelledError as exc:
# ``CancelledError`` reaches us in two very different shapes:
# 1. Pregel cancelled this task because a sibling failed
# (``asyncio.Task.cancelling() >= 1``). The framework already
# knows the run is failing and we must let cancellation
# propagate so the watchdog/cleanup code in the runner sees a
# cancelled future.
# 2. The node body itself raised ``asyncio.CancelledError`` (
# ``cancelling() == 0``). The runner would otherwise treat
# this as silent tear-down and the run would report
# ``success`` even though the node failed (LSD-1507). Convert
# it into :class:`NodeCancelledError` so it follows the same
# path as any other node failure.
if _is_user_raised_cancelled():
_finish_timed_attempt(config, attempt_ctx, exc)
raise NodeCancelledError(task.name) from exc
_finish_timed_attempt(config, attempt_ctx, exc)
raise
except Exception as exc:
_finish_timed_attempt(config, attempt_ctx, exc)
if SUPPORTS_EXC_NOTES:
+26 -3
View File
@@ -6,9 +6,13 @@ from typing import Any
from uuid import UUID
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import CheckpointMetadata, PendingWrite
from langgraph.checkpoint.base import (
EXCLUDED_METADATA_KEYS,
CheckpointMetadata,
PendingWrite,
)
from langgraph._internal._config import patch_checkpoint_map
from langgraph._internal._config import filter_to_user_tags, patch_checkpoint_map
from langgraph._internal._constants import (
CONF,
CONFIG_KEY_CHECKPOINT_NS,
@@ -40,12 +44,31 @@ def map_debug_tasks(tasks: Iterable[PregelExecutableTask]) -> Iterator[TaskPaylo
if task.config is not None and TAG_HIDDEN in task.config.get("tags", []):
continue
yield {
payload: TaskPayload = {
"id": task.id,
"name": task.name,
"input": task.input,
"triggers": task.triggers,
}
# Forward user-meaningful metadata only — drop langgraph's internal
# framework keys (langgraph_node/step/triggers/path/checkpoint_ns,
# thread_id, ...), which are redundant with the task's own fields and
# namespace. Keys like `lc_agent_name`, `ls_integration`, and any
# user-supplied metadata ride along. Filtered config tags are folded in
# under `tags`, mirroring the messages stream handler. (The comprehension
# also yields a fresh dict, so mutating `md` doesn't touch task.config.)
if task.config is not None:
md = {
k: v
for k, v in (task.config.get("metadata") or {}).items()
if k not in EXCLUDED_METADATA_KEYS
}
filtered_tags = filter_to_user_tags(task.config.get("tags"))
if filtered_tags is not None:
md["tags"] = filtered_tags
if md:
payload["metadata"] = md
yield payload
def is_multiple_channel_write(value: Any) -> bool:
+126 -9
View File
@@ -55,6 +55,10 @@ from langgraph._internal._constants import (
NS_SEP,
)
from langgraph.errors import GraphInterrupt, ParentCommand
from langgraph.pregel._remote_run_stream import (
_AsyncRemoteGraphRunStream,
_RemoteGraphRunStream,
)
from langgraph.pregel.protocol import PregelProtocol, StreamProtocol
from langgraph.types import (
All,
@@ -80,6 +84,8 @@ _CONF_DROPLIST = frozenset(
),
)
_V3_SUPPORTED_KWARGS = frozenset({"metadata", "headers"})
def _sanitize_config_value(v: Any) -> Any:
"""Recursively sanitize a config value to ensure it contains only primitives."""
@@ -186,6 +192,34 @@ class RemoteGraph(PregelProtocol):
)
return self.sync_client
def _reject_v3_unsupported(
self,
*,
control: Any,
transformers: Any,
interrupt_before: Any,
interrupt_after: Any,
extra_kwargs: dict[str, Any],
) -> None:
"""Raise NotImplementedError for kwargs unsupported by the v3 streaming path."""
for name, value in (
("control", control),
("transformers", transformers),
("interrupt_before", interrupt_before),
("interrupt_after", interrupt_after),
):
if value:
raise NotImplementedError(
f"RemoteGraph.stream_events(version='v3') does not support `{name}=`."
)
unknown = set(extra_kwargs) - _V3_SUPPORTED_KWARGS
if unknown:
raise NotImplementedError(
f"RemoteGraph.stream_events(version='v3') does not support "
f"the following kwargs: {sorted(unknown)!r}. "
f"Supported: {sorted(_V3_SUPPORTED_KWARGS)!r}."
)
def copy(self, update: dict[str, Any]) -> Self:
attrs = {**self.__dict__, **update}
return self.__class__(attrs.pop("assistant_id"), **attrs)
@@ -996,21 +1030,104 @@ class RemoteGraph(PregelProtocol):
else:
yield chunk
def stream_events(
self,
input: Any,
config: RunnableConfig | None = None,
*,
version: Literal["v1", "v2", "v3"] = "v2",
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
control: Any = None,
transformers: Sequence[Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> Any:
"""Stream events from this remote graph.
For `version="v3"`, returns a `_RemoteGraphRunStream` whose surface
matches the local `GraphRunStream`. For other versions, delegates to
`Runnable.stream_events`.
"""
if version != "v3":
return super().stream_events(input, config, version=version, **kwargs)
self._reject_v3_unsupported(
control=control,
transformers=transformers,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
extra_kwargs=kwargs,
)
sync_client = self._validate_sync_client()
sanitized = self._sanitize_config(merge_configs(self.config, config))
thread_id = sanitized.get("configurable", {}).pop("thread_id", None)
merged_headers = (
_merge_tracing_headers(headers) if self.distributed_tracing else headers
)
sdk_thread = sync_client.threads.stream(
thread_id=thread_id,
assistant_id=self.assistant_id,
headers=merged_headers,
)
return _RemoteGraphRunStream(
sync_client=sync_client,
sdk_thread=sdk_thread,
input=input,
config=sanitized,
metadata=kwargs.get("metadata"),
)
async def astream_events(
self,
input: Any,
config: RunnableConfig | None = None,
*,
version: Literal["v1", "v2"],
include_names: Sequence[All] | None = None,
include_types: Sequence[All] | None = None,
include_tags: Sequence[All] | None = None,
exclude_names: Sequence[All] | None = None,
exclude_types: Sequence[All] | None = None,
exclude_tags: Sequence[All] | None = None,
version: Literal["v1", "v2", "v3"] = "v2",
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
control: Any = None,
transformers: Sequence[Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> AsyncIterator[dict[str, Any]]:
raise NotImplementedError
) -> Any:
"""Async-stream events from this remote graph.
For `version="v3"`, awaits to an `_AsyncRemoteGraphRunStream`, matching
the local `Pregel.astream_events(version="v3")` awaitable contract:
`async with await rg.astream_events(..., version="v3") as run`. For
`version="v1"`/`"v2"`, raises NotImplementedError (use `astream`).
"""
if version != "v3":
raise NotImplementedError(
f"RemoteGraph.astream_events(version={version!r}) is not "
"implemented; use astream() for v1/v2 streaming or "
"version='v3'."
)
self._reject_v3_unsupported(
control=control,
transformers=transformers,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
extra_kwargs=kwargs,
)
client = self._validate_client()
sanitized = self._sanitize_config(merge_configs(self.config, config))
thread_id = sanitized.get("configurable", {}).pop("thread_id", None)
merged_headers = (
_merge_tracing_headers(headers) if self.distributed_tracing else headers
)
sdk_thread = client.threads.stream(
thread_id=thread_id,
assistant_id=self.assistant_id,
headers=merged_headers,
)
return _AsyncRemoteGraphRunStream(
client=client,
sdk_thread=sdk_thread,
input=input,
config=sanitized,
metadata=kwargs.get("metadata"),
)
@overload
def invoke(
+1 -1
View File
@@ -35,7 +35,7 @@ class ProtocolEvent(TypedDict):
"""
type: Literal["event"]
eventId: NotRequired[str]
event_id: NotRequired[str] # snake_case to match the langchain-protocol wire field
seq: NotRequired[int]
method: str # StreamMode value: "values", "messages", "custom", etc.
params: _ProtocolEventParams
@@ -9,7 +9,7 @@ from langchain_core.language_models.chat_model_stream import (
ChatModelStream,
)
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
from langchain_protocol.protocol import MessagesData
from langchain_protocol.protocol import LifecycleCause, MessagesData
from typing_extensions import NotRequired, TypedDict
from langgraph.errors import GraphDrained, GraphInterrupt
@@ -366,6 +366,7 @@ class LifecyclePayload(TypedDict, total=False):
namespace: list[str]
graph_name: NotRequired[str]
trigger_call_id: NotRequired[str]
cause: NotRequired[LifecycleCause]
error: NotRequired[str]
@@ -406,6 +407,18 @@ class _TasksLifecycleBase(StreamTransformer):
# Maps tracked namespace -> task_id of the parent task whose
# `TaskResultPayload` will close it.
self._open: dict[tuple[str, ...], str] = {}
# lc_agent_name observed at each namespace (first task event wins).
# Not read by the base discriminator (which only checks whether the
# current task carries an lc_agent_name); maintained as extension state
# for subclasses that project named subagents — e.g. a `run.subagents`
# transformer reads this to filter to nested runs that have a name.
self._lc_by_ns: dict[tuple[str, ...], str | None] = {}
# Pregel task_id -> triggering LLM tool_call_id, harvested from a task
# whose `input` is a `tool_call_with_context` dict (current shape) or a
# list of tool-call dicts (legacy shape). The child subgraph's segment
# `node:<task_id>` shares this task_id, so a subagent recovers the tool
# call that spawned it (cross-payload).
self._pending_tool_calls: dict[str, str] = {}
# --- Template-method hooks (subclass overrides) ---
@@ -418,6 +431,8 @@ class _TasksLifecycleBase(StreamTransformer):
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
*,
cause: LifecycleCause | None = None,
) -> None:
"""Fired once per discovered namespace (first observed task event)."""
raise NotImplementedError
@@ -443,18 +458,81 @@ class _TasksLifecycleBase(StreamTransformer):
if "result" in data:
self._handle_task_result(ns, data)
else:
self._handle_task_start(ns)
self._record_identity(ns, data)
self._record_pending_tool_calls(data)
self._handle_task_start(ns, data)
# Tasks events are folded into the synthesized projections;
# suppress from the main event log so iterators don't double-see
# the same information in two shapes.
return False
def _handle_task_start(self, ns: tuple[str, ...]) -> None:
def _record_identity(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
"""Record this namespace's `lc_agent_name` (first task event wins).
Runs for every task-start event, including `ns == self.scope` and
tracked children. Pregel emits parent-namespace tasks before
child-namespace tasks, so under that ordering the parent's identity is
recorded by the time a child event is evaluated in `_handle_task_start`.
"""
if ns in self._lc_by_ns:
return
metadata = data.get("metadata") or {}
self._lc_by_ns[ns] = metadata.get("lc_agent_name")
def _record_pending_tool_calls(self, data: dict[str, Any]) -> None:
"""Harvest a task's triggering tool_call_id keyed by its task id.
A tool-dispatch task seeds `task_id -> tool_call_id`; the spawned
subgraph's namespace segment `node:<task_id>` shares that id, letting
a subagent recover the tool call that caused it across payloads. Two
input shapes are handled: the current Pregel push model schedules each
tool call as its own task whose `input` is a `tool_call_with_context`
dict, while a legacy / batched model passes a list of tool-call dicts.
"""
task_id = data.get("id")
if not isinstance(task_id, str):
return
payload = data.get("input")
tool_call_id: str | None = None
# Current langgraph schedules each tool call as its own push task
# whose input is a `tool_call_with_context` dict.
if isinstance(payload, dict) and isinstance(payload.get("tool_call"), dict):
candidate = payload["tool_call"].get("id")
if isinstance(candidate, str):
tool_call_id = candidate
# Legacy / batched shape: input is a list of tool-call dicts.
elif isinstance(payload, list):
for tc in payload:
if isinstance(tc, dict) and isinstance(tc.get("id"), str):
tool_call_id = tc["id"] # first wins
break
if tool_call_id is not None:
self._pending_tool_calls[task_id] = tool_call_id
def _handle_task_start(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
if not self._should_track(ns) or ns in self._seen:
return
self._seen.add(ns)
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
self._on_started(ns, graph_name or None, trigger_call_id)
parsed_name, trigger_call_id = _parse_ns_segment(ns[-1])
metadata = data.get("metadata") or {}
child_lc = metadata.get("lc_agent_name")
# A subagent boundary is any nested run carrying an lc_agent_name (set
# by create_agent). Unnamed runs (lc_agent_name None) are excluded.
#
# A same-named nested agent — e.g. a subagent that invokes itself — is
# surfaced because it re-asserts its own lc_agent_name. The trade-off:
# a non-agent subgraph invoked inside a tool inherits the parent's
# lc_agent_name and will also surface (named after the parent). A caller
# that needs to exclude such a graph can null lc_agent_name in the
# config it invokes that graph with.
is_subagent = child_lc is not None
graph_name = child_lc if is_subagent else (parsed_name or None)
cause: LifecycleCause | None = None
if is_subagent and trigger_call_id is not None:
tool_call_id = self._pending_tool_calls.get(trigger_call_id)
if tool_call_id:
cause = {"type": "toolCall", "tool_call_id": str(tool_call_id)}
self._on_started(ns, graph_name, trigger_call_id, cause=cause)
if trigger_call_id is not None:
self._open[ns] = trigger_call_id
@@ -553,6 +631,8 @@ class LifecycleTransformer(_TasksLifecycleBase):
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
*,
cause: LifecycleCause | None = None,
) -> None:
if trigger_call_id is None:
# Without a task id we can't correlate a parent-result
@@ -563,6 +643,8 @@ class LifecycleTransformer(_TasksLifecycleBase):
if graph_name:
payload["graph_name"] = graph_name
payload["trigger_call_id"] = trigger_call_id
if cause is not None:
payload["cause"] = cause
self._channel.push(payload)
def _on_terminal(
@@ -625,6 +707,8 @@ class SubgraphTransformer(_TasksLifecycleBase):
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
*,
cause: LifecycleCause | None = None,
) -> None:
if self._mux is None:
return
@@ -633,6 +717,10 @@ class SubgraphTransformer(_TasksLifecycleBase):
except RuntimeError:
return
handle_cls = AsyncSubgraphRunStream if child_mux.is_async else SubgraphRunStream
# `cause` is intentionally ignored here: it is a wire/lifecycle-channel
# concern (carried on `LifecyclePayload`), not something the in-process
# subgraph navigation handle exposes. The argument is accepted only to
# keep the `_on_started` template signature uniform across transformers.
handle = handle_cls(
mux=child_mux,
path=ns,
@@ -737,7 +825,12 @@ class SubgraphTransformer(_TasksLifecycleBase):
for child_ns, status, error in self._pop_terminal_transitions(ns, data):
await self._aon_terminal(child_ns, status, error)
else:
self._handle_task_start(ns)
# Mirror the sync `process` bookkeeping so the async lane
# observes parent identity / tool calls before discriminating
# a subagent boundary.
self._record_identity(ns, data)
self._record_pending_tool_calls(data)
self._handle_task_start(ns, data)
keep = False
else:
keep = True
+10
View File
@@ -150,6 +150,16 @@ class TaskPayload(TypedDict):
"""Input data passed to the task."""
triggers: list[str]
"""List of triggers that caused this task to be executed (e.g. channel writes)."""
metadata: NotRequired[dict[str, Any]]
"""Framework-resolved metadata associated with the task.
Generic dict carrier following the messages-stream pattern. Populated by
`map_debug_tasks` from `task.config["metadata"]` when non-empty, so the
same keys `stream_mode="messages"` consumers see (e.g. `lc_agent_name`,
`langgraph_node`, `langgraph_step`) are available to stream transformers.
Consumers should ignore unrecognized keys.
"""
class TaskResultPayload(TypedDict):
+1 -1
View File
@@ -26,7 +26,7 @@ classifiers = [
dependencies = [
"langchain-core>=1.4.0,<2",
"langgraph-checkpoint>=4.1.0,<5.0.0",
"langgraph-sdk>=0.3.0,<0.4.0",
"langgraph-sdk>=0.4.1,<0.5.0",
"langgraph-prebuilt>=1.1.0,<1.2.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
+98 -1
View File
@@ -1,7 +1,8 @@
import pytest
from langchain_core.callbacks import AsyncCallbackManager
from langchain_core.callbacks import AsyncCallbackManager, BaseCallbackHandler
from langgraph._internal._config import get_async_callback_manager_for_config
from langgraph.graph import StateGraph
pytestmark = pytest.mark.anyio
@@ -17,3 +18,99 @@ def test_new_async_manager_merges_tags_with_config() -> None:
config = {"callbacks": None, "tags": ["a"]}
manager = get_async_callback_manager_for_config(config, tags=["b"])
assert manager.inheritable_tags == ["a", "b"]
class _TrackingCallback(BaseCallbackHandler):
def __init__(self) -> None:
self.called = False
def on_chain_start(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
self.called = True
async def test_with_config_callbacks_preserved_in_astream_events() -> None:
"""A callback bound via .with_config(...) must survive when
astream_events injects its own internal callback handler.
Pre-fix: ensure_config overwrites the callbacks key, dropping the
bound handler. Post-fix: the handler list is merged.
"""
builder = StateGraph(dict)
builder.add_node("node", lambda state: state)
builder.add_edge("__start__", "node")
cb = _TrackingCallback()
graph = builder.compile().with_config({"callbacks": [cb]})
async for _ in graph.astream_events({}, version="v2"):
pass
assert cb.called, "user-bound callback was dropped by ensure_config overwrite"
async def test_with_config_configurable_preserved_on_invoke() -> None:
"""A configurable key bound via .with_config(...) must survive when
invoke-time config supplies a different configurable key.
Pre-fix: ensure_config overwrites the entire configurable dict.
Post-fix: the two dicts are shallow-merged per key.
"""
builder = StateGraph(dict)
captured: dict = {}
def node(state, config): # noqa: ANN001
captured.update(config.get("configurable") or {})
return state
builder.add_node("node", node)
builder.add_edge("__start__", "node")
graph = builder.compile().with_config({"configurable": {"ls_agent_type": "root"}})
await graph.ainvoke({}, {"configurable": {"thread_id": "T1"}})
assert captured.get("ls_agent_type") == "root", (
"bound configurable key was dropped by ensure_config overwrite"
)
assert captured.get("thread_id") == "T1", "invoke-time key not present"
async def test_with_config_metadata_preserved_on_invoke() -> None:
"""A metadata key bound via .with_config(...) must survive when
invoke-time config supplies a different metadata key.
Pre-fix: ensure_config overwrites the entire metadata dict.
Post-fix: the two dicts are shallow-merged per key.
"""
builder = StateGraph(dict)
captured: dict = {}
def node(state, config): # noqa: ANN001
captured.update(config.get("metadata") or {})
return state
builder.add_node("node", node)
builder.add_edge("__start__", "node")
graph = builder.compile().with_config({"metadata": {"user_id": "U1"}})
await graph.ainvoke({}, {"metadata": {"correlation_id": "C1"}})
assert captured.get("user_id") == "U1", (
"bound metadata key was dropped by ensure_config overwrite"
)
assert captured.get("correlation_id") == "C1", "invoke-time key not present"
async def test_with_config_tags_preserved_on_invoke() -> None:
"""Tags bound via .with_config(...) must survive when invoke-time
config supplies its own tags.
Pre-fix: ensure_config overwrites the entire tags list.
Post-fix: tags are concatenated (matching merge_configs behavior;
no deduplication, no sorting).
"""
builder = StateGraph(dict)
captured: list = []
def node(state, config): # noqa: ANN001
captured.extend(config.get("tags") or [])
return state
builder.add_node("node", node)
builder.add_edge("__start__", "node")
graph = builder.compile().with_config({"tags": ["bound"]})
await graph.ainvoke({}, {"tags": ["invoke"]})
assert "bound" in captured, "bound tag was dropped by ensure_config overwrite"
assert "invoke" in captured, "invoke-time tag not present"
+185
View File
@@ -0,0 +1,185 @@
"""Tests for langgraph.pregel.debug helpers."""
from __future__ import annotations
from langgraph.pregel.debug import map_debug_tasks
class _FakeTask:
"""Minimal stand-in for PregelExecutableTask covering only what map_debug_tasks reads."""
def __init__(
self,
*,
id: str,
name: str,
input: object,
triggers: list[str],
config: dict | None,
) -> None:
self.id = id
self.name = name
self.input = input
self.triggers = triggers
self.config = config
def test_map_debug_tasks_forwards_metadata_when_present() -> None:
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config={"metadata": {"lc_agent_name": "weather_agent"}},
)
payloads = list(map_debug_tasks([task]))
assert len(payloads) == 1
payload = payloads[0]
assert payload["id"] == "t1"
assert payload["name"] == "tools"
assert payload["metadata"] == {"lc_agent_name": "weather_agent"}
def test_map_debug_tasks_omits_metadata_when_empty() -> None:
# Empty metadata dict in config: don't include metadata in the payload.
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config={"metadata": {}},
)
payloads = list(map_debug_tasks([task]))
assert "metadata" not in payloads[0]
def test_map_debug_tasks_omits_metadata_when_absent() -> None:
# No metadata key in config: don't include metadata in the payload.
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config={},
)
payloads = list(map_debug_tasks([task]))
assert "metadata" not in payloads[0]
def test_map_debug_tasks_handles_none_config() -> None:
# task.config can be None; should not crash.
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config=None,
)
payloads = list(map_debug_tasks([task]))
assert len(payloads) == 1
assert "metadata" not in payloads[0]
def test_map_debug_tasks_filters_framework_metadata_keys() -> None:
"""Internal framework keys are dropped from the forwarded metadata; only
user-meaningful keys (lc_agent_name, ls_integration, user metadata) ride
along. The framework keys (langgraph_*, thread_id, checkpoint_*) are
redundant with the task's own fields/namespace.
"""
md = {
"lc_agent_name": "weather_agent",
"ls_integration": "langchain_create_agent",
"my_user_key": "x",
"thread_id": "thread-1",
"langgraph_step": 1,
"langgraph_node": "tools",
"langgraph_path": ("__pregel_pull", "tools"),
"langgraph_checkpoint_ns": "tools:abc",
"checkpoint_ns": "",
}
task = _FakeTask(
id="t1", name="tools", input=[], triggers=["x"], config={"metadata": md}
)
payload = next(iter(map_debug_tasks([task])))
assert payload["metadata"] == {
"lc_agent_name": "weather_agent",
"ls_integration": "langchain_create_agent",
"my_user_key": "x",
}
def test_map_debug_tasks_omits_metadata_when_only_framework_keys() -> None:
"""A task whose metadata is entirely framework keys (e.g. a plain
StateGraph node) yields no `metadata` key after filtering.
"""
md = {
"thread_id": "thread-1",
"langgraph_step": 1,
"langgraph_node": "worker",
"langgraph_checkpoint_ns": "worker:abc",
"checkpoint_ns": "",
}
task = _FakeTask(
id="t1", name="worker", input=[], triggers=["x"], config={"metadata": md}
)
payload = next(iter(map_debug_tasks([task])))
assert "metadata" not in payload
def test_map_debug_tasks_metadata_is_copied_not_referenced() -> None:
"""Mutating the source config after emission must not affect the
payload TaskPayload.metadata is a defensive copy.
"""
md = {"lc_agent_name": "a"}
task = _FakeTask(
id="t1", name="tools", input=[], triggers=["x"], config={"metadata": md}
)
payload = next(iter(map_debug_tasks([task])))
md["lc_agent_name"] = "MUTATED"
assert payload["metadata"]["lc_agent_name"] == "a"
def test_map_debug_tasks_folds_filtered_tags_into_metadata() -> None:
"""Config tags are folded into TaskPayload.metadata under `tags`, with
langchain's internal `seq:step:*` tags filtered out — mirroring the
messages stream handler so both channels surface the same tag set."""
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config={
"metadata": {"lc_agent_name": "weather_agent"},
"tags": ["seq:step:1", "user-tag", "session-123"],
},
)
payload = next(iter(map_debug_tasks([task])))
assert payload["metadata"]["lc_agent_name"] == "weather_agent"
assert payload["metadata"]["tags"] == ["user-tag", "session-123"]
def test_map_debug_tasks_omits_tags_when_only_seq_step() -> None:
"""If the only tags are internal `seq:step:*` markers, no `tags` key is
added (matches the messages handler's `if filtered_tags:` guard)."""
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config={"metadata": {"lc_agent_name": "a"}, "tags": ["seq:step:1"]},
)
payload = next(iter(map_debug_tasks([task])))
assert "tags" not in payload["metadata"]
def test_map_debug_tasks_adds_tags_even_without_other_metadata() -> None:
"""Filtered tags surface even when config has no metadata dict."""
task = _FakeTask(
id="t1",
name="tools",
input=[],
triggers=["x"],
config={"tags": ["user-tag"]},
)
payload = next(iter(map_debug_tasks([task])))
assert payload["metadata"] == {"tags": ["user-tag"]}
@@ -0,0 +1,658 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from langgraph.pregel._remote_run_stream import (
_AsyncRemoteGraphRunStream,
_ChannelProjection,
_ProjectionRegistry,
_RemoteGraphRunStream,
_translate_command_input,
)
from langgraph.pregel.remote import (
_V3_SUPPORTED_KWARGS,
RemoteGraph,
)
from langgraph.types import Command
def _make_sync_adapter(*, run_start_returns=None, run_start_raises=None):
sync_client = MagicMock()
sdk_thread = MagicMock()
sdk_thread.thread_id = "thread-abc"
sdk_thread.__enter__ = MagicMock(return_value=sdk_thread)
sdk_thread.__exit__ = MagicMock(return_value=None)
sdk_thread.run = MagicMock()
if run_start_raises is not None:
sdk_thread.run.start = MagicMock(side_effect=run_start_raises)
else:
sdk_thread.run.start = MagicMock(
return_value=run_start_returns or {"run_id": "run-xyz"}
)
adapter = _RemoteGraphRunStream(
sync_client=sync_client,
sdk_thread=sdk_thread,
input={"x": 1},
config={"configurable": {}},
metadata=None,
)
return adapter, sync_client, sdk_thread
def test_enter_calls_sdk_enter_then_run_start_and_captures_run_id():
adapter, _, sdk_thread = _make_sync_adapter()
with adapter as stream:
assert stream is adapter
sdk_thread.__enter__.assert_called_once()
sdk_thread.run.start.assert_called_once_with(
input={"x": 1}, config={"configurable": {}}, metadata=None
)
assert adapter._run_id == "run-xyz"
def test_exit_delegates_to_sdk_exit_with_exc_info():
adapter, _, sdk_thread = _make_sync_adapter()
with adapter:
pass
sdk_thread.__exit__.assert_called_once_with(None, None, None)
def test_enter_unwinds_sdk_cm_when_run_start_raises():
adapter, _, sdk_thread = _make_sync_adapter(
run_start_raises=RuntimeError("start boom")
)
with pytest.raises(RuntimeError, match="start boom"):
with adapter:
pytest.fail("body should not run")
sdk_thread.__enter__.assert_called_once()
sdk_thread.__exit__.assert_called_once()
exc_info = sdk_thread.__exit__.call_args.args
assert exc_info[0] is RuntimeError
assert isinstance(exc_info[1], RuntimeError)
assert adapter._run_id is None
def test_output_interrupted_interrupts_passthrough():
adapter, _, sdk_thread = _make_sync_adapter()
sdk_thread.output = {"foo": 1}
sdk_thread.interrupted = True
sdk_thread.interrupts = [{"interrupt_id": "i1", "namespace": [], "value": "v"}]
with adapter as stream:
assert stream.output == {"foo": 1}
assert stream.interrupted is True
assert stream.interrupts == [
{"interrupt_id": "i1", "namespace": [], "value": "v"}
]
def test_sync_projection_attrs_forward_to_sdk():
adapter, _, sdk_thread = _make_sync_adapter()
sdk_thread.values = object()
sdk_thread.messages = object()
sdk_thread.tool_calls = object()
sdk_thread.subgraphs = object()
with adapter as stream:
assert stream.values is sdk_thread.values
assert stream.messages is sdk_thread.messages
assert stream.tool_calls is sdk_thread.tool_calls
assert stream.subgraphs is sdk_thread.subgraphs
assert set(stream.extensions) == set(_ProjectionRegistry._NATIVE)
assert stream.extensions["values"] is sdk_thread.values
def test_projection_registry_typed_decoded_and_custom():
sdk = MagicMock()
sdk.values = object()
sdk.messages = object()
sdk.tool_calls = object()
sdk.subgraphs = object()
custom_named = object()
sdk.extensions = {"my_custom": custom_named}
registry = _ProjectionRegistry(sdk)
# Typed channels resolve to the SDK's decoded projections.
assert registry["values"] is sdk.values
assert registry["tool_calls"] is sdk.tool_calls
assert registry["subgraphs"] is sdk.subgraphs
# Channels without a typed projection resolve to a decoding _ChannelProjection.
ckpt = registry["checkpoints"]
assert isinstance(ckpt, _ChannelProjection)
assert ckpt._channel == "checkpoints"
assert isinstance(registry["updates"], _ChannelProjection)
# A non-protocol name is a specific custom-extension channel.
assert registry["my_custom"] is custom_named
# Enumerable set is the typed + decoded channels (no `lifecycle`, no `debug`).
assert list(registry) == [
"values",
"messages",
"tool_calls",
"subgraphs",
"updates",
"checkpoints",
"tasks",
"custom",
]
assert len(registry) == 8
def test_channel_projection_decodes_params_data():
sdk = MagicMock()
# Real wire events carry `method`; the SDK `DataDecoder` yields matching
# events' `params.data` and skips dataless and off-channel ones.
sdk.subscribe = MagicMock(
return_value=iter(
[
{"method": "checkpoints", "params": {"data": {"n": 1}}},
{"method": "checkpoints", "params": {}}, # no data -> skipped
{"method": "checkpoints", "params": {"data": {"n": 2}}},
{"method": "lifecycle", "params": {"data": {"n": 3}}}, # other channel
]
)
)
proj = _ChannelProjection(sdk, "checkpoints")
assert list(proj) == [{"n": 1}, {"n": 2}]
sdk.subscribe.assert_called_once_with(["checkpoints"])
@pytest.mark.anyio
async def test_channel_projection_decodes_params_data_async():
"""Async lane mirrors the sync lane: `async for` over the SDK's async
subscription, decoded through the same `DataDecoder`."""
class _FakeAsyncEvents:
def __init__(self, items):
self._items = list(items)
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
raise StopAsyncIteration
return self._items.pop(0)
sdk = MagicMock()
sdk.subscribe = MagicMock(
return_value=_FakeAsyncEvents(
[
{"method": "checkpoints", "params": {"data": {"n": 1}}},
{"method": "checkpoints", "params": {}}, # no data -> skipped
{"method": "checkpoints", "params": {"data": {"n": 2}}},
{"method": "lifecycle", "params": {"data": {"n": 3}}}, # other channel
]
)
)
proj = _ChannelProjection(sdk, "checkpoints")
assert [item async for item in proj] == [{"n": 1}, {"n": 2}]
sdk.subscribe.assert_called_once_with(["checkpoints"])
def test_sync_adapter_translates_command_input():
sync_client = MagicMock()
sdk_thread = MagicMock()
adapter = _RemoteGraphRunStream(
sync_client=sync_client,
sdk_thread=sdk_thread,
input=Command(resume="go"),
config=None,
metadata=None,
)
assert adapter._start_kwargs["input"] == "go"
def test_iter_caches_first_subscription():
adapter, _, sdk_thread = _make_sync_adapter()
fake_events = [object(), object(), object()]
sdk_thread.events = iter(fake_events)
with adapter as stream:
first = iter(stream)
second = iter(stream)
assert first is second
assert list(first) == fake_events
def test_abort_cancels_run_and_closes_sdk():
adapter, sync_client, sdk_thread = _make_sync_adapter()
with adapter as stream:
stream.abort()
sync_client.runs.cancel.assert_called_once_with(
"thread-abc", "run-xyz", wait=False
)
sdk_thread.close.assert_called_once()
def test_abort_before_enter_skips_cancel_but_closes_sdk():
adapter, sync_client, sdk_thread = _make_sync_adapter()
adapter.abort()
sync_client.runs.cancel.assert_not_called()
sdk_thread.close.assert_called_once()
def test_abort_is_idempotent():
adapter, sync_client, sdk_thread = _make_sync_adapter()
with adapter as stream:
stream.abort()
stream.abort()
assert sync_client.runs.cancel.call_count == 1
assert sdk_thread.close.call_count == 1
def test_abort_swallows_cancel_failure_and_still_closes():
adapter, sync_client, sdk_thread = _make_sync_adapter()
sync_client.runs.cancel.side_effect = RuntimeError("cancel boom")
with adapter as stream:
stream.abort()
sdk_thread.close.assert_called_once()
def test_sync_interleave_delegates_to_interleave_projections():
adapter, _, sdk_thread = _make_sync_adapter()
pairs = [("values", {"x": 1}), ("messages", object())]
sdk_thread.interleave_projections.return_value = pairs
with adapter as stream:
result = list(stream.interleave("values", "messages"))
assert result == pairs
sdk_thread.interleave_projections.assert_called_once_with(["values", "messages"])
def test_async_adapter_has_no_interleave():
"""Async adapter intentionally lacks `interleave` (mirrors local
`AsyncGraphRunStream`, which doesn't have one either). Async callers
compose with `asyncio.gather` / `asyncio.as_completed`.
"""
assert not hasattr(_AsyncRemoteGraphRunStream, "interleave")
def _make_async_adapter(*, run_start_returns=None, run_start_raises=None):
client = MagicMock()
client.runs.cancel = AsyncMock()
sdk_thread = MagicMock()
sdk_thread.thread_id = "thread-abc"
sdk_thread.__aenter__ = AsyncMock(return_value=sdk_thread)
sdk_thread.__aexit__ = AsyncMock(return_value=None)
sdk_thread.close = AsyncMock()
sdk_thread.run = MagicMock()
if run_start_raises is not None:
sdk_thread.run.start = AsyncMock(side_effect=run_start_raises)
else:
sdk_thread.run.start = AsyncMock(
return_value=run_start_returns or {"run_id": "run-xyz"}
)
adapter = _AsyncRemoteGraphRunStream(
client=client,
sdk_thread=sdk_thread,
input={"x": 1},
config={"configurable": {}},
metadata=None,
)
return adapter, client, sdk_thread
@pytest.mark.anyio
async def test_aenter_calls_sdk_aenter_then_run_start_and_captures_run_id():
adapter, _, sdk_thread = _make_async_adapter()
async with adapter as stream:
assert stream is adapter
sdk_thread.__aenter__.assert_awaited_once()
sdk_thread.run.start.assert_awaited_once_with(
input={"x": 1}, config={"configurable": {}}, metadata=None
)
assert adapter._run_id == "run-xyz"
@pytest.mark.anyio
async def test_aexit_delegates_to_sdk_aexit():
adapter, _, sdk_thread = _make_async_adapter()
async with adapter:
pass
sdk_thread.__aexit__.assert_awaited_once_with(None, None, None)
@pytest.mark.anyio
async def test_aenter_unwinds_sdk_cm_when_run_start_raises():
adapter, _, sdk_thread = _make_async_adapter(
run_start_raises=RuntimeError("start boom")
)
with pytest.raises(RuntimeError, match="start boom"):
async with adapter:
pytest.fail("body should not run")
sdk_thread.__aenter__.assert_awaited_once()
sdk_thread.__aexit__.assert_awaited_once()
assert adapter._run_id is None
@pytest.mark.anyio
async def test_async_output_interrupted_interrupts_passthrough():
adapter, _, sdk_thread = _make_async_adapter()
async def _fake_output_awaitable():
return {"foo": 1}
sdk_thread.output = _fake_output_awaitable()
sdk_thread.interrupted = True
sdk_thread.interrupts = [{"interrupt_id": "i1", "namespace": [], "value": "v"}]
async with adapter as stream:
assert await stream.output() == {"foo": 1}
assert await stream.interrupted() is True
assert await stream.interrupts() == [
{"interrupt_id": "i1", "namespace": [], "value": "v"}
]
@pytest.mark.anyio
async def test_async_projection_attrs_forward_to_sdk():
adapter, _, sdk_thread = _make_async_adapter()
sdk_thread.values = object()
sdk_thread.messages = object()
sdk_thread.tool_calls = object()
sdk_thread.subgraphs = object()
async with adapter as stream:
assert stream.values is sdk_thread.values
assert stream.messages is sdk_thread.messages
assert stream.tool_calls is sdk_thread.tool_calls
assert stream.subgraphs is sdk_thread.subgraphs
assert set(stream.extensions) == set(_ProjectionRegistry._NATIVE)
assert stream.extensions["messages"] is sdk_thread.messages
@pytest.mark.anyio
async def test_aiter_caches_first_subscription():
adapter, _, sdk_thread = _make_async_adapter()
class _FakeAsyncEvents:
def __init__(self, items):
self._items = list(items)
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
raise StopAsyncIteration
return self._items.pop(0)
sdk_thread.events = _FakeAsyncEvents([object(), object()])
async with adapter as stream:
first = stream.__aiter__()
second = stream.__aiter__()
assert first is second
@pytest.mark.anyio
async def test_async_abort_cancels_run_and_closes_sdk():
adapter, client, sdk_thread = _make_async_adapter()
async with adapter as stream:
await stream.abort()
client.runs.cancel.assert_awaited_once_with("thread-abc", "run-xyz", wait=False)
sdk_thread.close.assert_awaited_once()
@pytest.mark.anyio
async def test_async_abort_before_aenter_skips_cancel():
adapter, client, sdk_thread = _make_async_adapter()
await adapter.abort()
client.runs.cancel.assert_not_awaited()
sdk_thread.close.assert_awaited_once()
@pytest.mark.anyio
async def test_async_abort_swallows_cancel_failure():
adapter, client, sdk_thread = _make_async_adapter()
client.runs.cancel.side_effect = RuntimeError("cancel boom")
async with adapter as stream:
await stream.abort()
sdk_thread.close.assert_awaited_once()
def _make_remote_graph() -> RemoteGraph:
sync_client = MagicMock()
async_client = MagicMock()
rg = RemoteGraph(
"agent",
client=async_client,
sync_client=sync_client,
)
return rg
def test_reject_v3_unsupported_passes_when_all_clear():
rg = _make_remote_graph()
rg._reject_v3_unsupported(
control=None,
transformers=None,
interrupt_before=None,
interrupt_after=None,
extra_kwargs={},
)
@pytest.mark.parametrize(
"kwarg_name,kwarg_value",
[
("control", object()),
("transformers", [object()]),
("interrupt_before", ["node_a"]),
("interrupt_after", ["node_b"]),
],
)
def test_reject_v3_unsupported_raises_per_kwarg(kwarg_name, kwarg_value):
rg = _make_remote_graph()
kwargs = dict(
control=None,
transformers=None,
interrupt_before=None,
interrupt_after=None,
extra_kwargs={},
)
kwargs[kwarg_name] = kwarg_value
with pytest.raises(NotImplementedError, match=f"`{kwarg_name}=`"):
rg._reject_v3_unsupported(**kwargs)
def test_reject_v3_unsupported_raises_on_unknown_extra_kwarg():
rg = _make_remote_graph()
with pytest.raises(NotImplementedError, match="context"):
rg._reject_v3_unsupported(
control=None,
transformers=None,
interrupt_before=None,
interrupt_after=None,
extra_kwargs={"context": {}},
)
def test_reject_v3_unsupported_allows_metadata_and_headers():
rg = _make_remote_graph()
rg._reject_v3_unsupported(
control=None,
transformers=None,
interrupt_before=None,
interrupt_after=None,
extra_kwargs={"metadata": {"a": 1}, "headers": {"X": "y"}},
)
def test_translate_command_input_surfaces_raw_resume_value():
# The v3 server wraps the resume `input` as {"resume": input} itself, so the
# wire `input` must be the raw resume value, not the serialized dataclass.
assert _translate_command_input(Command(resume="go")) == "go"
assert _translate_command_input(Command(resume={"id": "v"})) == {"id": "v"}
def test_translate_command_input_rejects_goto_and_update():
with pytest.raises(NotImplementedError, match="goto"):
_translate_command_input(Command(goto="node_b"))
with pytest.raises(NotImplementedError, match="update"):
_translate_command_input(Command(update={"a": 1}))
def test_translate_command_input_passes_through_non_command():
assert _translate_command_input({"a": 1}) == {"a": 1}
assert _translate_command_input(None) is None
def test_v3_supported_kwargs_known_set():
assert _V3_SUPPORTED_KWARGS == frozenset({"metadata", "headers"})
def test_stream_events_v3_constructs_sdk_thread_with_sanitized_args():
sync_client = MagicMock()
sdk_thread = MagicMock()
sync_client.threads.stream.return_value = sdk_thread
rg = RemoteGraph(
"agent",
client=MagicMock(),
sync_client=sync_client,
)
result = rg.stream_events(
{"input_key": 1},
config={"configurable": {"thread_id": "t1", "user": "u"}},
version="v3",
)
assert isinstance(result, _RemoteGraphRunStream)
sync_client.threads.stream.assert_called_once()
call = sync_client.threads.stream.call_args
assert call.kwargs["thread_id"] == "t1"
assert call.kwargs["assistant_id"] == "agent"
assert call.kwargs["headers"] is None
def test_stream_events_v3_passes_none_thread_id_when_absent():
sync_client = MagicMock()
sync_client.threads.stream.return_value = MagicMock()
rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client)
rg.stream_events({"x": 1}, version="v3")
call = sync_client.threads.stream.call_args
assert call.kwargs["thread_id"] is None
def test_stream_events_v3_rejects_unsupported_kwargs_before_sdk_call():
sync_client = MagicMock()
rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client)
with pytest.raises(NotImplementedError, match="control"):
rg.stream_events({"x": 1}, version="v3", control=object())
sync_client.threads.stream.assert_not_called()
def test_stream_events_v3_translates_command_input():
sync_client = MagicMock()
sync_client.threads.stream.return_value = MagicMock()
rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client)
# Resume Command surfaces its raw resume value as the wire `input`; the v3
# server wraps it as {"resume": input} once it detects the interrupt.
adapter = rg.stream_events(Command(resume="go"), version="v3")
assert adapter._start_kwargs["input"] == "go"
def test_stream_events_v3_rejects_goto_update_command():
rg = RemoteGraph("agent", client=MagicMock(), sync_client=MagicMock())
with pytest.raises(NotImplementedError, match="goto"):
rg.stream_events(Command(goto="node_b"), version="v3")
def test_stream_events_v3_strips_checkpoint_keys_from_configurable():
sync_client = MagicMock()
sync_client.threads.stream.return_value = MagicMock()
rg = RemoteGraph("agent", client=MagicMock(), sync_client=sync_client)
adapter = rg.stream_events(
{"x": 1},
config={
"configurable": {
"thread_id": "t1",
"checkpoint_id": "c1",
"checkpoint_ns": "ns",
"user": "u",
}
},
version="v3",
)
sent_config = adapter._start_kwargs["config"]
assert "checkpoint_id" not in sent_config["configurable"]
assert "checkpoint_ns" not in sent_config["configurable"]
assert sent_config["configurable"]["user"] == "u"
def test_stream_events_v3_merges_tracing_headers_when_distributed_tracing(
monkeypatch,
):
from langgraph.pregel import remote as remote_mod
sync_client = MagicMock()
sync_client.threads.stream.return_value = MagicMock()
rg = RemoteGraph(
"agent",
client=MagicMock(),
sync_client=sync_client,
distributed_tracing=True,
)
captured = {}
def fake_merge(headers):
captured["arg"] = headers
return {"x-ls-trace": "1", **(headers or {})}
monkeypatch.setattr(remote_mod, "_merge_tracing_headers", fake_merge)
rg.stream_events({"x": 1}, version="v3", headers={"X-Custom": "y"})
assert captured["arg"] == {"X-Custom": "y"}
sent_headers = sync_client.threads.stream.call_args.kwargs["headers"]
assert sent_headers["x-ls-trace"] == "1"
assert sent_headers["X-Custom"] == "y"
def test_stream_events_v3_passes_headers_unchanged_without_tracing():
sync_client = MagicMock()
sync_client.threads.stream.return_value = MagicMock()
rg = RemoteGraph(
"agent",
client=MagicMock(),
sync_client=sync_client,
distributed_tracing=False,
)
rg.stream_events({"x": 1}, version="v3", headers={"X-Custom": "y"})
sent_headers = sync_client.threads.stream.call_args.kwargs["headers"]
assert sent_headers == {"X-Custom": "y"}
def test_stream_events_non_v3_delegates_to_super():
rg = RemoteGraph("agent", client=MagicMock(), sync_client=MagicMock())
sync_client_attr = rg.sync_client
try:
rg.stream_events({"x": 1}, version="v2")
except Exception:
pass
sync_client_attr.threads.stream.assert_not_called()
@pytest.mark.anyio
async def test_astream_events_v3_constructs_sdk_thread():
client = MagicMock()
sdk_thread = MagicMock()
client.threads.stream.return_value = sdk_thread
rg = RemoteGraph("agent", client=client, sync_client=MagicMock())
result = await rg.astream_events(
{"x": 1},
config={"configurable": {"thread_id": "t1"}},
version="v3",
)
assert isinstance(result, _AsyncRemoteGraphRunStream)
call = client.threads.stream.call_args
assert call.kwargs["thread_id"] == "t1"
assert call.kwargs["assistant_id"] == "agent"
@pytest.mark.anyio
async def test_astream_events_v3_rejects_unsupported_kwargs():
client = MagicMock()
rg = RemoteGraph("agent", client=client, sync_client=MagicMock())
with pytest.raises(NotImplementedError, match="transformers"):
await rg.astream_events({"x": 1}, version="v3", transformers=[object()])
client.threads.stream.assert_not_called()
@pytest.mark.anyio
async def test_astream_events_non_v3_raises_not_implemented():
rg = RemoteGraph("agent", client=MagicMock(), sync_client=MagicMock())
with pytest.raises(NotImplementedError, match="not implemented"):
await rg.astream_events({"x": 1}, version="v2")
+140 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import contextlib
import operator
import sys
import threading
@@ -35,7 +36,13 @@ from langgraph._internal._runnable import RunnableCallable
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.errors import GraphInterrupt, NodeError, NodeTimeoutError, ParentCommand
from langgraph.errors import (
GraphInterrupt,
NodeCancelledError,
NodeError,
NodeTimeoutError,
ParentCommand,
)
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.pregel import NodeBuilder, Pregel
@@ -63,6 +70,18 @@ NEEDS_CONTEXTVARS = pytest.mark.skipif(
reason="Python 3.11+ is required for async contextvars support",
)
# `asyncio.Task.cancelling()` is Python 3.11+. The LSD-1507 fix in
# `langgraph/pregel/_retry.py` falls back to a no-op on 3.10 (preserves the
# existing CancelledError-as-silent-tear-down behaviour) because there is no
# reliable way to distinguish user-raised from framework-initiated
# cancellation without that API. Tests for the converted behaviour gate on
# the same Python version boundary.
NEEDS_TASK_CANCELLING = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="LSD-1507 user-cancellation conversion requires Python 3.11+ "
"(asyncio.Task.cancelling)",
)
def test_should_retry_on_single_exception():
"""Test retry with a single exception type."""
@@ -2802,3 +2821,123 @@ def test_error_handler_resumes_after_crash_multiple_nodes():
assert call_count["handler_b"] == 2 # ran again on resume
assert "recovered_a:a" in result["results"]
assert "recovered_b:b" in result["results"]
@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_arun_with_retry_user_raised_cancelled_becomes_node_cancelled():
class UserCancelsProc:
async def ainvoke(self, input, config):
raise asyncio.CancelledError("nope")
task = _make_task(UserCancelsProc(), name="user-cancel")
with pytest.raises(NodeCancelledError) as excinfo:
await arun_with_retry(task, retry_policy=None)
assert excinfo.value.node == "user-cancel"
# original CancelledError chained for debugging
assert isinstance(excinfo.value.__cause__, asyncio.CancelledError)
@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_arun_with_retry_user_raised_cancelled_with_timeout_policy():
"""The timeout path runs the node in a child task; the conversion must
still trigger for user-raised ``CancelledError``."""
class UserCancelsProc:
async def ainvoke(self, input, config):
raise asyncio.CancelledError
task = _make_task(
UserCancelsProc(), timeout=_idle_timeout(1.0), name="user-cancel-timed"
)
with pytest.raises(NodeCancelledError) as excinfo:
await arun_with_retry(task, retry_policy=None)
assert excinfo.value.node == "user-cancel-timed"
def test_run_with_retry_sync_node_raising_cancelled_becomes_node_cancelled():
class SyncUserCancelsProc:
def invoke(self, input, config):
raise asyncio.CancelledError("sync nope")
task = _make_task(SyncUserCancelsProc(), timeout=None, name="sync-user-cancel")
with pytest.raises(NodeCancelledError) as excinfo:
run_with_retry(task, retry_policy=None)
assert excinfo.value.node == "sync-user-cancel"
assert isinstance(excinfo.value.__cause__, asyncio.CancelledError)
@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_arun_with_retry_external_cancel_propagates_as_cancelled():
"""When the asyncio task running ``arun_with_retry`` is cancelled from the
outside, the cancellation must still propagate as
``asyncio.CancelledError``. Converting it to ``NodeCancelledError`` would
break the runner's ability to cancel sibling tasks during cleanup."""
started = asyncio.Event()
observed: list[BaseException] = []
class SlowProc:
async def ainvoke(self, input, config):
started.set()
await asyncio.sleep(10.0)
return "never"
task = _make_task(SlowProc(), timeout=None, name="external-cancel")
async def runner():
try:
await arun_with_retry(task, retry_policy=None)
except BaseException as exc:
observed.append(exc)
raise
bg = asyncio.create_task(runner())
await started.wait()
bg.cancel()
# We expect the cancellation to surface to us as well; swallow it here so
# the test runner's own task isn't poisoned by the cancel.
with contextlib.suppress(asyncio.CancelledError):
await bg
assert observed, "runner did not observe any exception"
# Framework cancellation must remain a CancelledError, not be rewritten as
# NodeCancelledError.
assert isinstance(observed[0], asyncio.CancelledError)
assert not isinstance(observed[0], NodeCancelledError)
@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_pregel_user_raised_cancellederror_fails_run():
"""End-to-end: a two-branch graph where one branch raises
``asyncio.CancelledError`` must fail the run instead of returning
a partial-success state. This is the LSD-1507 customer scenario."""
class _S(TypedDict, total=False):
vals: Annotated[list[str], operator.add]
async def ok(state: _S) -> _S:
return {"vals": ["ok"]}
async def boom(state: _S) -> _S:
raise asyncio.CancelledError("user-raised in node body")
graph = (
StateGraph(_S)
.add_node("ok", ok)
.add_node("boom", boom)
.add_edge(START, "ok")
.add_edge(START, "boom")
.add_edge("ok", END)
.add_edge("boom", END)
.compile()
)
with pytest.raises(NodeCancelledError) as excinfo:
await graph.ainvoke({"vals": []})
assert excinfo.value.node == "boom"
@@ -35,20 +35,25 @@ def _tasks_start(
*,
task_id: str,
name: str,
metadata: dict[str, Any] | None = None,
input: Any = None,
) -> dict[str, Any]:
"""Build a `tasks` ProtocolEvent carrying a TaskPayload (start)."""
data: dict[str, Any] = {
"id": task_id,
"name": name,
"input": input,
"triggers": [],
}
if metadata is not None:
data["metadata"] = metadata
return {
"type": "event",
"method": "tasks",
"params": {
"namespace": namespace,
"timestamp": TS,
"data": {
"id": task_id,
"name": name,
"input": None,
"triggers": [],
},
"data": data,
},
}
@@ -404,3 +409,272 @@ def test_stream_events_v3_with_nested_parent_ns_scopes_lifecycle() -> None:
assert ns[:1] == ("outer:abc",), (
f"namespace {ns} not within scoped prefix ('outer:abc',)"
)
# ---------------------------------------------------------------------------
# Parsed-segment fallback (no subagent boundary)
# ---------------------------------------------------------------------------
def test_no_metadata_falls_through_to_existing_behavior() -> None:
"""Tasks events without metadata produce the same output as before T4."""
mux = _build_lifecycle_mux()
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool"))
[payload] = _drain_lifecycle(mux)
assert payload["graph_name"] == "agent"
assert payload["trigger_call_id"] == "abc"
assert "cause" not in payload
def test_empty_metadata_dict_falls_through() -> None:
"""An explicit empty metadata dict is treated the same as no metadata."""
mux = _build_lifecycle_mux()
mux.push(_tasks_start(["agent:abc"], task_id="t1", name="tool", metadata={}))
[payload] = _drain_lifecycle(mux)
assert payload["graph_name"] == "agent"
assert "cause" not in payload
# ---------------------------------------------------------------------------
# Subagent discrimination via lc_agent_name transition
# ---------------------------------------------------------------------------
#
# A nested task is a subagent iff its metadata["lc_agent_name"] is present and
# differs from its PARENT namespace's lc_agent_name. These tests replicate the
# empirically-verified `create_agent` stream shape synthetically:
#
# - A supervisor created via `create_agent(name="supervisor")` emits its own
# node tasks (model, tools) at ns=(), each with
# metadata["lc_agent_name"] == "supervisor".
# - The parent `tools` task at ns=() carries a `tool_call_with_context`
# dict as its `input` (with the LLM tool_call_id at
# input["tool_call"]["id"]) and a task `id`. (A legacy / batched shape
# passes a list of tool-call dicts instead; both are exercised below.)
# - When a tool body invokes an inner `create_agent(name="weather_agent")`,
# the inner agent's node tasks stream at ns=("tools:<taskid>",) with
# metadata["lc_agent_name"] == "weather_agent", sharing the SAME <taskid>
# as the parent `tools` task.
# - A plain StateGraph (no name) inherits the parent's lc_agent_name, so its
# child lc == parent lc -> NOT a subagent.
def test_lifecycle_uses_lc_agent_name_for_subagent() -> None:
"""A nested run whose lc_agent_name differs from its parent's is a subagent.
graph_name becomes the child's lc_agent_name; cause is recovered by joining
the child segment's task-id to the parent push task's tool call. This uses
the production `tool_call_with_context` dict input shape current langgraph
emits (tool_call_id at input["tool_call"]["id"]).
"""
mux = _build_lifecycle_mux()
# Supervisor's `tools` push task at scope ns: carries its own lc_agent_name
# and a `tool_call_with_context` dict as `input`. Each tool call is its own
# push task, and the task id seeds the child segment.
mux.push(
_tasks_start(
[],
task_id="tools_task_1",
name="tools",
metadata={"lc_agent_name": "supervisor"},
input={
"__type": "tool_call_with_context",
"tool_call": {
"name": "call_weather",
"args": {"city": "Boston"},
"id": "call_w",
"type": "tool_call",
},
"state": {},
},
)
)
# Inner weather_agent's first node task streams under the parent `tools`
# task's namespace segment (shared task id) with its own lc_agent_name.
mux.push(
_tasks_start(
["tools:tools_task_1"],
task_id="inner_model_1",
name="model",
metadata={"lc_agent_name": "weather_agent"},
)
)
payloads = _drain_lifecycle(mux)
started = [p for p in payloads if p["event"] == "started"]
[subagent] = [p for p in started if p["namespace"] == ["tools:tools_task_1"]]
assert subagent["graph_name"] == "weather_agent", (
"graph_name should be the child's lc_agent_name, not the parsed segment"
)
assert subagent["cause"] == {"type": "toolCall", "tool_call_id": "call_w"}, (
"cause should recover the triggering tool_call_id from the parent push "
"task's tool_call_with_context input via the shared task id"
)
def test_lifecycle_subagent_cause_from_legacy_list_input() -> None:
"""cause recovery also handles the legacy / batched list input shape.
A parent task whose `input` is a list of tool-call dicts (rather than a
`tool_call_with_context` dict) still seeds the tool_call_id join.
"""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="tools_task_1",
name="tools",
metadata={"lc_agent_name": "supervisor"},
input=[{"name": "call_weather", "args": {"city": "SF"}, "id": "call_w"}],
)
)
mux.push(
_tasks_start(
["tools:tools_task_1"],
task_id="inner_model_1",
name="model",
metadata={"lc_agent_name": "weather_agent"},
)
)
payloads = _drain_lifecycle(mux)
started = [p for p in payloads if p["event"] == "started"]
[subagent] = [p for p in started if p["namespace"] == ["tools:tools_task_1"]]
assert subagent["graph_name"] == "weather_agent"
assert subagent["cause"] == {"type": "toolCall", "tool_call_id": "call_w"}
def test_lifecycle_same_name_nested_run_is_surfaced() -> None:
"""A nested run whose lc_agent_name matches the parent's is still surfaced.
A subagent that invokes itself re-asserts its own lc_agent_name, so child
lc == parent lc. The discriminator surfaces any nested run carrying an
lc_agent_name, so the recursive call is reported (named after the agent,
with the triggering tool call as cause).
Trade-off: a non-agent subgraph that merely inherited the parent's
lc_agent_name would also surface here. That is accepted; a caller can null
lc_agent_name in the config it invokes such a graph with to exclude it.
"""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="tools_task_1",
name="tools",
metadata={"lc_agent_name": "weather_agent"},
input={
"__type": "tool_call_with_context",
"tool_call": {
"name": "recurse",
"args": {},
"id": "call_x",
"type": "tool_call",
},
"state": {},
},
)
)
# The agent invokes itself: the nested run re-asserts the SAME lc_agent_name.
mux.push(
_tasks_start(
["tools:tools_task_1"],
task_id="inner_node_1",
name="model",
metadata={"lc_agent_name": "weather_agent"},
)
)
payloads = _drain_lifecycle(mux)
started = [p for p in payloads if p["event"] == "started"]
[nested] = [p for p in started if p["namespace"] == ["tools:tools_task_1"]]
assert nested["graph_name"] == "weather_agent", (
"a same-named nested run (e.g. self-recursion) must still be surfaced"
)
assert nested["cause"] == {"type": "toolCall", "tool_call_id": "call_x"}
def test_lifecycle_unnamed_nested_agent_is_not_subagent() -> None:
"""A nested run with lc_agent_name None is excluded (not a subagent)."""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="tools_task_1",
name="tools",
metadata={"lc_agent_name": "supervisor"},
input={
"__type": "tool_call_with_context",
"tool_call": {
"name": "lookup",
"args": {},
"id": "call_x",
"type": "tool_call",
},
"state": {},
},
)
)
mux.push(
_tasks_start(
["plain:tools_task_1"],
task_id="inner_node_1",
name="inner_node",
metadata={"lc_agent_name": None},
)
)
payloads = _drain_lifecycle(mux)
started = [p for p in payloads if p["event"] == "started"]
[nested] = [p for p in started if p["namespace"] == ["plain:tools_task_1"]]
assert nested["graph_name"] == "plain"
assert "cause" not in nested
def test_lifecycle_subagent_terminal_roundtrip() -> None:
"""A detected subagent closes with `completed` when its parent task results.
Pushes the subagent's `started` (via the `tool_call_with_context` parent
plus the child task event) and then the parent push task's terminal
result, asserting the namespace is closed and the `started` payload's
projected graph_name / cause survive the roundtrip.
"""
mux = _build_lifecycle_mux()
mux.push(
_tasks_start(
[],
task_id="tools_task_1",
name="tools",
metadata={"lc_agent_name": "supervisor"},
input={
"__type": "tool_call_with_context",
"tool_call": {
"name": "call_weather",
"args": {"city": "Boston"},
"id": "call_w",
"type": "tool_call",
},
"state": {},
},
)
)
mux.push(
_tasks_start(
["tools:tools_task_1"],
task_id="inner_model_1",
name="model",
metadata={"lc_agent_name": "weather_agent"},
)
)
# The parent push task (id=tools_task_1, at scope ns) finishes, closing
# the subagent subgraph that streamed under `tools:tools_task_1`.
mux.push(_tasks_result([], task_id="tools_task_1", name="tools"))
payloads = _drain_lifecycle(mux)
ns = ["tools:tools_task_1"]
subagent = [p for p in payloads if p["namespace"] == ns]
assert [p["event"] for p in subagent] == ["started", "completed"]
started, _completed = subagent
assert started["graph_name"] == "weather_agent"
assert started["cause"] == {"type": "toolCall", "tool_call_id": "call_w"}
+127
View File
@@ -15,12 +15,14 @@ from unittest.mock import MagicMock, patch
import langsmith
import pytest
from langchain_core.callbacks import BaseCallbackHandler, CallbackManager
from langchain_core.runnables import RunnableConfig
from langchain_core.tracers import LangChainTracer
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._config import (
_is_not_empty,
_merge_callbacks,
ensure_config,
get_callback_manager_for_config,
)
@@ -427,3 +429,128 @@ def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
"thread_id": "th-123",
"user_id": "uid-1",
}
class _TrackingCB(BaseCallbackHandler):
"""Minimal callback handler used only as a sentinel for merge tests."""
def __init__(self, tag: str) -> None:
self.tag = tag
def __eq__(self, other: object) -> bool:
return isinstance(other, _TrackingCB) and self.tag == other.tag
def __hash__(self) -> int:
return hash(self.tag)
def test_merge_callbacks_none_base_list_new() -> None:
cb = _TrackingCB("a")
merged = _merge_callbacks(None, [cb])
assert merged == [cb]
def test_merge_callbacks_list_base_list_new() -> None:
a, b = _TrackingCB("a"), _TrackingCB("b")
merged = _merge_callbacks([a], [b])
assert merged == [a, b]
def test_merge_callbacks_list_base_manager_new() -> None:
a = _TrackingCB("a")
mgr = CallbackManager(handlers=[_TrackingCB("b")])
merged = _merge_callbacks([a], mgr)
assert isinstance(merged, CallbackManager)
assert _TrackingCB("a") in merged.handlers
assert _TrackingCB("b") in merged.handlers
def test_merge_callbacks_manager_base_list_new() -> None:
mgr = CallbackManager(handlers=[_TrackingCB("a")])
b = _TrackingCB("b")
merged = _merge_callbacks(mgr, [b])
assert isinstance(merged, CallbackManager)
assert _TrackingCB("a") in merged.handlers
assert _TrackingCB("b") in merged.handlers
def test_merge_callbacks_manager_base_manager_new() -> None:
mgr_a = CallbackManager(handlers=[_TrackingCB("a")])
mgr_b = CallbackManager(handlers=[_TrackingCB("b")])
merged = _merge_callbacks(mgr_a, mgr_b)
assert isinstance(merged, CallbackManager)
assert _TrackingCB("a") in merged.handlers
assert _TrackingCB("b") in merged.handlers
def test_merge_callbacks_none_base_none_new() -> None:
merged = _merge_callbacks(None, None)
assert merged is None
def test_ensure_config_merges_configurable_across_configs() -> None:
a = {"configurable": {"ls_agent_type": "root"}}
b = {"configurable": {"thread_id": "T1"}}
merged = ensure_config(a, b)
assert merged["configurable"]["ls_agent_type"] == "root"
assert merged["configurable"]["thread_id"] == "T1"
def test_ensure_config_configurable_later_wins_per_key() -> None:
a = {"configurable": {"shared": "from_a", "only_a": "A"}}
b = {"configurable": {"shared": "from_b", "only_b": "B"}}
merged = ensure_config(a, b)
assert merged["configurable"]["shared"] == "from_b" # later wins per key
assert merged["configurable"]["only_a"] == "A"
assert merged["configurable"]["only_b"] == "B"
def test_ensure_config_merges_metadata_across_configs() -> None:
a = {"metadata": {"user_id": "U1"}}
b = {"metadata": {"correlation_id": "C1"}}
merged = ensure_config(a, b)
assert merged["metadata"]["user_id"] == "U1"
assert merged["metadata"]["correlation_id"] == "C1"
def test_ensure_config_metadata_later_wins_per_key() -> None:
a = {"metadata": {"shared": "from_a"}}
b = {"metadata": {"shared": "from_b"}}
merged = ensure_config(a, b)
assert merged["metadata"]["shared"] == "from_b"
def test_ensure_config_merges_tags_across_configs() -> None:
a = {"tags": ["alpha"]}
b = {"tags": ["beta"]}
merged = ensure_config(a, b)
assert merged["tags"] == ["alpha", "beta"]
def test_ensure_config_tags_concat_preserves_order_and_duplicates() -> None:
# Plain concat (matches merge_configs in this file — no dedup, no sort).
a = {"tags": ["shared", "alpha"]}
b = {"tags": ["shared", "beta"]}
merged = ensure_config(a, b)
assert merged["tags"] == ["shared", "alpha", "shared", "beta"]
def test_ensure_config_merges_callbacks_across_configs() -> None:
a_cb = _TrackingCB("a")
b_cb = _TrackingCB("b")
merged = ensure_config({"callbacks": [a_cb]}, {"callbacks": [b_cb]})
assert merged["callbacks"] == [a_cb, b_cb]
def test_ensure_config_none_inputs_ignored() -> None:
# mixed with None should not raise
merged = ensure_config(None, {"tags": ["t"]}, None)
assert merged["tags"] == ["t"]
def test_ensure_config_empty_inputs() -> None:
# everything empty -> defaults
merged = ensure_config()
assert merged["tags"] == []
assert merged["configurable"] == {}
assert merged["callbacks"] is None
+48 -42
View File
@@ -1,13 +1,13 @@
"""create_agent-based example exercising the v3 ``tools`` channel.
"""create_agent-based example exercising the v3 `tools` channel.
`thread.tool_calls` and the underlying ``tools`` channel only emit
`thread.tool_calls` and the underlying `tools` channel only emit
events when an actual model issues a tool call through langchain's
agent stack. The synthetic ``streaming_graph.py`` hand-builds
agent stack. The synthetic `streaming_graph.py` hand-builds
`AIMessage(tool_calls=[...])` and a `ToolMessage` via the messages
reducer that gets persisted in state but never produces tool-call
telemetry on the wire. This graph fixes that by going through
`create_agent` with a real tool, driven by a `GenericFakeChatModel` so
the test stays hermetic (no `ANTHROPIC_API_KEY` required).
`create_agent` with a real tool, driven by a hermetic fake chat model
(no `ANTHROPIC_API_KEY` required).
Flow on `run.start`:
@@ -25,7 +25,8 @@ from typing import Any
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
@@ -36,51 +37,56 @@ def search(query: str) -> str:
class _ToolBindingFakeChatModel(FakeMessagesListChatModel):
"""Fake chat model that satisfies `create_agent`'s ``bind_tools`` call.
"""Stateless fake chat model driving a single `search` tool call.
``create_agent`` calls ``model.bind_tools(tools)`` to attach the tool
schema (``langchain/agents/factory.py:1284``). The base
``FakeMessagesListChatModel`` inherits ``BaseChatModel.bind_tools``,
which raises ``NotImplementedError``. We don't actually need the
bound schema the fake replays scripted ``AIMessage``s with their
own ``tool_calls`` field so override ``bind_tools`` as a no-op.
`create_agent` calls `model.bind_tools(tools)` to attach the tool
schema (`langchain/agents/factory.py:1284`). The base
`FakeMessagesListChatModel` inherits `BaseChatModel.bind_tools`,
which raises `NotImplementedError`, so `bind_tools` is overridden as
a no-op (the reply is hand-built and already carries `tool_calls`).
``FakeMessagesListChatModel`` is preferred over
``GenericFakeChatModel`` here because the latter's ``_stream``
breaks the message into content chunks and **drops ``tool_calls``**
when content is empty, causing the v2 streaming path inside
``create_agent`` to raise ``RuntimeError("v2 stream finished
without producing a message")``. ``FakeMessagesListChatModel``
falls back to the default ``_stream`` that yields the whole
message in one chunk, preserving ``tool_calls``.
The reply is derived from conversation state rather than a cycling
response list: the `search` tool call is issued until a `ToolMessage`
appears, then a terminating `AIMessage`. This avoids the response-index
parity flake where `FakeMessagesListChatModel.responses` is shared
process-wide and cycles `0 -> 1 -> 0`; a run that started mid-cycle
(e.g. on a reused server worker) would reply `"done."` first and emit
no tool call. Being order-independent, every run emits exactly one
tool call regardless of how many times the model was previously called.
`FakeMessagesListChatModel` is subclassed (rather than
`GenericFakeChatModel`) because the latter's `_stream` breaks the
message into content chunks and drops `tool_calls` when content is
empty, causing the v2 streaming path inside `create_agent` to raise
`RuntimeError("v2 stream finished without producing a message")`.
The inherited `_stream` yields the whole message in one chunk,
preserving `tool_calls`.
"""
def bind_tools(self, tools: Any, **kwargs: Any) -> _ToolBindingFakeChatModel:
return self
# Two scripted turns. ``FakeMessagesListChatModel`` cycles through
# ``responses`` (resetting to index 0 after the last) so the graph
# can be run many times without restart; per run, ``create_agent``
# invokes the model exactly twice (once to issue the tool call,
# once after the tool result to produce the terminating answer).
_supervisor_responses: list[AIMessage] = [
AIMessage(
content="",
id="ai-tools-1",
tool_calls=[
{
"id": "tc-1",
"name": "search",
"args": {"query": "v3"},
}
],
),
AIMessage(content="done.", id="ai-tools-2"),
]
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: Any = None,
**kwargs: Any,
) -> ChatResult:
if any(isinstance(m, ToolMessage) for m in messages):
response = AIMessage(content="done.", id="ai-tools-done")
else:
response = AIMessage(
content="",
id="ai-tools-call",
tool_calls=[{"id": "tc-1", "name": "search", "args": {"query": "v3"}}],
)
return ChatResult(generations=[ChatGeneration(message=response)])
_supervisor_model = _ToolBindingFakeChatModel(responses=_supervisor_responses)
# `responses` is a required field on `FakeMessagesListChatModel`, but the
# overridden `_generate` derives its reply from state and never reads it.
_supervisor_model = _ToolBindingFakeChatModel(responses=[])
graph = create_agent(
+1 -1
View File
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import EncryptionContext
__version__ = "0.3.15"
__version__ = "0.4.2"
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
+230 -207
View File
@@ -18,7 +18,7 @@ import contextlib
import random
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypedDict
from typing import Any, Literal, TypedDict, cast
from langchain_core.language_models.chat_model_stream import AsyncChatModelStream
from langchain_protocol import Event, SubscribeParams
@@ -26,6 +26,16 @@ from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.schema import QueryParamTypes
from langgraph_sdk.stream.controller import _SeenEventIds
from langgraph_sdk.stream.decoders import (
DataDecoder,
Decoder,
ExtensionsDecoder,
MessagesDecoder,
SubgraphsDecoder,
ToolCallsDecoder,
validate_interleave_channels,
)
from langgraph_sdk.stream.subscription import compute_union_filter, infer_channel
from langgraph_sdk.stream.transport import (
AsyncProtocolTransport,
EventStreamHandle,
@@ -348,6 +358,7 @@ class _ValuesProjection:
raise RuntimeError("AsyncThreadStream not entered — use `async with`.")
params: SubscribeParams = {"channels": ["values"]}
sub = self._thread._register_subscription(params)
decoder = DataDecoder("values")
try:
await self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
@@ -357,12 +368,8 @@ class _ValuesProjection:
item = await sub.queue.get()
if item is None:
return
params_field = item.get("params") or {}
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if data is not None:
yield data
for out in decoder.feed(item):
yield out
finally:
self._thread._unregister_subscription(sub.id)
@@ -397,7 +404,13 @@ class _MessagesProjection:
return
params = _exact_namespace_params(["messages"], self._namespace)
sub = self._thread._register_subscription(params)
active: dict[str, AsyncChatModelStream] = {}
decoder = MessagesDecoder(
namespace=self._namespace,
stream_factory=lambda *, namespace, node, message_id: AsyncChatModelStream(
namespace=namespace, node=node, message_id=message_id
),
)
registered: list[AsyncChatModelStream] = []
try:
await self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
@@ -405,59 +418,12 @@ class _MessagesProjection:
item = await sub.queue.get()
if item is None:
return
params_field = item.get("params") or {}
if _event_namespace(params_field) != self._namespace:
continue
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if not isinstance(data, dict):
continue
event_type = data.get("event")
if event_type == "message-start":
message_id = _message_event_id(data)
key = _message_route_key(data, fallback=message_id)
metadata = (
data.get("metadata")
if isinstance(data.get("metadata"), dict)
else {}
)
stream = AsyncChatModelStream(
namespace=list(self._namespace),
node=metadata.get("langgraph_node") if metadata else None,
message_id=message_id,
)
active[key] = stream
for stream in decoder.feed(item):
self._thread._register_active_message_stream(stream)
stream.dispatch(data)
registered.append(stream)
yield stream
else:
key = _message_route_key(data)
stream = active.get(key)
if stream is None and key == "__single__" and len(active) == 1:
# Content-block events (content-block-start /
# content-block-delta / content-block-finish /
# message-finish) don't carry the message ``id``
# on the wire, so ``_message_route_key`` returns
# ``__single__`` while the active stream was
# registered under ``message:<id>``. When exactly
# one stream is active, that mismatch is
# unambiguous -- the events belong to it.
# Events that DO carry an explicit id which
# doesn't match any active stream are still
# dropped (orphan-delta safety, see
# ``test_messages_orphan_delta_without_matching_key_is_dropped``).
stream = next(iter(active.values()))
if stream is None:
continue
stream.dispatch(data)
if event_type in ("message-finish", "error"):
self._thread._unregister_active_message_stream(stream)
for route_key, candidate in list(active.items()):
if candidate is stream:
del active[route_key]
finally:
for stream in active.values():
for stream in registered:
self._thread._unregister_active_message_stream(stream)
self._thread._unregister_subscription(sub.id)
@@ -950,10 +916,17 @@ class _SubgraphsProjection:
raise RuntimeError("AsyncThreadStream not entered - use `async with`.")
params = _subgraph_subscription_params(self._scope)
sub = self._thread._register_subscription(params)
seen: set[tuple[str, ...]] = set()
active: dict[tuple[str, ...], ScopedStreamHandle] = {}
# Activate root inbox so scope-level messages events consumed here are
# forwarded to `thread.messages` even after the shared SSE ends.
decoder = SubgraphsDecoder(
scope=self._scope,
handle_factory=lambda *, path, graph_name, trigger_call_id: (
ScopedStreamHandle(
thread=self._thread,
path=path,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
)
),
)
root_inbox: asyncio.Queue[Event | None] | None = (
self._thread._activate_root_messages_inbox() if not self._scope else None
)
@@ -965,80 +938,14 @@ class _SubgraphsProjection:
if item is None:
return
params_field = item.get("params") or {}
namespace = _event_namespace(params_field)
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if not isinstance(data, dict):
continue
method = item.get("method")
# Route events at a child namespace (or deeper) to that child
# handle's channel inbox so sequential child-projection
# consumption works without opening a second SSE.
ns_tuple = tuple(namespace)
routed_to_child = False
for child_path, child_handle in active.items():
child_len = len(child_path)
if (
len(ns_tuple) >= child_len
and ns_tuple[:child_len] == child_path
):
child_handle._push_event(item)
routed_to_child = True
break
# Scope-level messages events are not routed to any child; forward
# them to the root inbox so `thread.messages` can drain them after
# this projection finishes (dedup prevents the SSE from replaying).
if (
not routed_to_child
and root_inbox is not None
and method == "messages"
and tuple(namespace) == self._scope
root_inbox is not None
and item.get("method") == "messages"
and tuple(_event_namespace(params_field)) == self._scope
):
root_inbox.put_nowait(item)
if method == "tasks":
if "result" in data:
self._apply_tasks_result(namespace, data, active)
elif _is_direct_child(namespace, self._scope):
path = tuple(namespace)
if path not in seen:
seen.add(path)
graph_name, trigger_call_id = _parse_namespace_segment(
path[-1]
)
handle = ScopedStreamHandle(
thread=self._thread,
path=path,
graph_name=graph_name or None,
trigger_call_id=trigger_call_id,
)
active[path] = handle
yield handle
elif (
method == "lifecycle"
and data.get("event") == "started"
and _is_direct_child(namespace, self._scope)
):
# ``create_deep_agent`` and similar surfaces signal
# subagent invocation via a child-namespace
# ``lifecycle: started`` event rather than a ``tasks``
# event. JS does the same (see ``langgraphjs``
# ``stream/handles/subgraphs.ts``).
path = tuple(namespace)
if path not in seen:
seen.add(path)
graph_name, trigger_call_id = _parse_namespace_segment(path[-1])
handle = ScopedStreamHandle(
thread=self._thread,
path=path,
graph_name=graph_name or None,
trigger_call_id=trigger_call_id,
)
active[path] = handle
yield handle
for handle in decoder.feed(item):
yield handle
finally:
# Determine terminal status from the parent run's lifecycle result.
# If _run_done resolved as errored, force-complete remaining children
@@ -1049,32 +956,13 @@ class _SubgraphsProjection:
result = run_done.result()
if isinstance(result, _RunTerminal) and result.status == "errored":
terminal_status = "failed"
for handle in active.values():
for handle in decoder._active.values():
if handle.status == "started":
handle._finish(terminal_status)
self._thread._unregister_subscription(sub.id)
if root_inbox is not None:
root_inbox.put_nowait(None)
def _apply_tasks_result(
self,
namespace: list[str],
data: dict[str, Any],
active: dict[tuple[str, ...], ScopedStreamHandle],
) -> None:
result_id = data.get("id")
if not result_id:
return
parent_path = tuple(namespace)
for child_path, handle in list(active.items()):
if child_path[:-1] != parent_path:
continue
if handle.trigger_call_id != result_id:
continue
status, error = _terminal_from_tasks_result(data)
handle._finish(status, error)
del active[child_path]
class ToolCallHandle:
"""Async handle for one root-scope tool call."""
@@ -1161,7 +1049,18 @@ class _ToolCallsProjection:
raise RuntimeError("AsyncThreadStream not entered - use `async with`.")
params = _exact_namespace_params(["tools"], self._namespace)
sub = self._thread._register_subscription(params)
active: dict[str, ToolCallHandle] = {}
decoder = ToolCallsDecoder(
namespace=self._namespace,
handle_factory=lambda *, tool_call_id, name, input, namespace: (
ToolCallHandle(
tool_call_id=tool_call_id,
name=name,
input=input,
namespace=namespace,
)
),
)
registered: list[ToolCallHandle] = []
try:
await self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
@@ -1169,52 +1068,10 @@ class _ToolCallsProjection:
item = await sub.queue.get()
if item is None:
return
params_field = item.get("params") or {}
if _event_namespace(params_field) != self._namespace:
continue
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if not isinstance(data, dict):
continue
event_type = data.get("event")
tool_call_id = data.get("tool_call_id")
if not isinstance(tool_call_id, str):
continue
if event_type == "tool-started":
tool_name = data.get("tool_name")
if not isinstance(tool_name, str):
tool_name = ""
handle = ToolCallHandle(
tool_call_id=tool_call_id,
name=tool_name,
input=data.get("input"),
namespace=list(self._namespace),
)
active[tool_call_id] = handle
for handle in decoder.feed(item):
self._thread._register_active_tool_call(handle)
registered.append(handle)
yield handle
elif event_type == "tool-output-delta":
handle = active.get(tool_call_id)
delta = data.get("delta")
if handle is not None and isinstance(delta, str):
handle._push_delta(delta)
elif event_type == "tool-finished":
handle = active.pop(tool_call_id, None)
if handle is not None:
self._thread._unregister_active_tool_call(handle)
handle._finish(data.get("output"))
elif event_type == "tool-error":
handle = active.pop(tool_call_id, None)
if handle is not None:
self._thread._unregister_active_tool_call(handle)
message = data.get("message")
handle._fail(
RuntimeError(
str(message) if message else "Tool call errored"
)
)
finally:
# Read terminal error from _run_done if it is already resolved.
# We do NOT block here: callers who need a terminal observation
@@ -1226,14 +1083,15 @@ class _ToolCallsProjection:
if run_done is not None and run_done.done() and not run_done.cancelled():
terminal = run_done.result()
terminal_err = terminal.error
err = (
err: BaseException = (
terminal_err
if terminal_err is not None
else RuntimeError("Tool call stream closed before terminal tool event.")
)
for handle in active.values():
self._thread._unregister_active_tool_call(handle)
for handle in list(decoder._active.values()):
handle._fail(err)
for handle in registered:
self._thread._unregister_active_tool_call(handle)
self._thread._unregister_subscription(sub.id)
@@ -1280,6 +1138,7 @@ class _ExtensionProjection:
if self._namespace:
params["namespaces"] = [self._namespace]
sub = self._thread._register_subscription(params)
decoder = ExtensionsDecoder(name=self._name)
try:
if self._thread._closed:
return
@@ -1289,12 +1148,8 @@ class _ExtensionProjection:
item = await sub.queue.get()
if item is None:
return
event_params = item.get("params") or {}
data = (
event_params.get("data") if isinstance(event_params, dict) else None
)
if isinstance(data, dict):
yield data
for out in decoder.feed(item):
yield out
finally:
self._thread._unregister_subscription(sub.id)
@@ -1558,6 +1413,174 @@ class AsyncThreadStream:
params["depth"] = depth
return self._subscription_iter(params)
async def interleave_projections(
self, channels: list[str]
) -> AsyncIterator[tuple[str, Any]]:
"""Yield `(channel_name, item)` tuples across multiple projections.
One shared subscription drives all per-channel decoders; items arrive
in server-emit order (the SDK analog of `GraphRunStream.interleave`).
Args:
channels: Flat list of `"values"`, `"messages"`, `"tool_calls"`,
`"subgraphs"`, and/or extension names. Built-ins yield their
typed item (snapshot dict / `AsyncChatModelStream` /
`ToolCallHandle` / `ScopedStreamHandle`); an extension yields
its payload dict, keyed by the bare extension name.
Note:
Handles and streams are yielded eagerly (before their sub-stream
completes), so items arrive interleaved in real time. To receive a
fully-resolved handle (output already populated), use the dedicated
`thread.tool_calls` / `thread.messages` projections instead.
"""
validate_interleave_channels(channels)
if self._transport is None:
raise RuntimeError("AsyncThreadStream not entered — use `async with`.")
decoders: dict[str, Decoder] = {}
sub_params: list[SubscribeParams] = []
for ch in channels:
if ch == "values":
decoders[ch] = DataDecoder("values")
sub_params.append({"channels": ["values"]})
elif ch in ("updates", "checkpoints", "tasks"):
# Plain payload channels (local Updates/Checkpoints/Tasks
# analog). Root-scope filter is load-bearing: a co-requested
# unscoped `values` widens the merged subscription to all
# namespaces, so the decoder itself keeps subgraph payloads out.
decoders[ch] = DataDecoder(ch, namespace=[])
sub_params.append(_exact_namespace_params([ch], []))
elif ch == "messages":
decoders[ch] = MessagesDecoder(
namespace=[],
stream_factory=lambda *, namespace, node, message_id: (
AsyncChatModelStream(
namespace=namespace, node=node, message_id=message_id
)
),
)
sub_params.append(_exact_namespace_params(["messages"], []))
elif ch == "tool_calls":
decoders[ch] = ToolCallsDecoder(
namespace=[],
handle_factory=lambda *, tool_call_id, name, input, namespace: (
ToolCallHandle(
tool_call_id=tool_call_id,
name=name,
input=input,
namespace=namespace,
)
),
)
sub_params.append(_exact_namespace_params(["tools"], []))
elif ch == "subgraphs":
decoders[ch] = SubgraphsDecoder(
scope=(),
handle_factory=lambda *, path, graph_name, trigger_call_id: (
ScopedStreamHandle(
thread=self,
path=path,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
)
),
)
sub_params.append(_subgraph_subscription_params(()))
else:
decoders[ch] = ExtensionsDecoder(name=ch)
sub_params.append({"channels": [f"custom:{ch}"]})
if not sub_params:
return
merged = cast(
SubscribeParams,
compute_union_filter(cast(list[dict[str, Any]], sub_params)),
)
subgraphs = decoders.get("subgraphs")
# Track decoder-created handles so teardown can finalize anything still
# in flight; otherwise an awaiting `handle.output` / `handle.messages`
# would hang after an early break or run termination.
registered_tool_calls: list[ToolCallHandle] = []
registered_message_streams: list[AsyncChatModelStream] = []
try:
async for event in self._subscription_iter(merged):
if subgraphs is not None:
for item in subgraphs.feed(event):
yield ("subgraphs", item)
wire = infer_channel(event)
public = self._interleave_public_name(wire)
# subgraphs is driven separately above (it consumes all events); never dispatch it here.
if public is not None and public != "subgraphs":
decoder = decoders.get(public)
if decoder is not None:
for item in decoder.feed(event):
if public == "tool_calls":
self._register_active_tool_call(item)
registered_tool_calls.append(item)
elif public == "messages":
self._register_active_message_stream(item)
registered_message_streams.append(item)
yield (public, item)
finally:
self._finalize_interleave_decoders(
decoders.get("tool_calls"),
subgraphs,
registered_tool_calls,
registered_message_streams,
)
@staticmethod
def _interleave_public_name(wire: str | None) -> str | None:
"""Map a wire channel name to the public channel name used in interleave tuples."""
if wire is None:
return None
if wire == "tools":
return "tool_calls"
if wire.startswith("custom:"):
return wire[len("custom:") :]
return wire # values, messages (tasks/lifecycle pass through with no decoder match)
def _finalize_interleave_decoders(
self,
tool_calls: Decoder | None,
subgraphs: Decoder | None,
registered_tool_calls: list[ToolCallHandle],
registered_message_streams: list[AsyncChatModelStream],
) -> None:
"""Finalize in-flight handles when `interleave_projections` tears down.
Mirrors the terminal handling of the dedicated `_ToolCallsProjection` /
`_SubgraphsProjection`: in-flight tool calls are failed (so awaiting
`handle.output` can't hang) and discovered subgraph children are
force-completed with the run's terminal status.
"""
run_done = self._run_done
resolved = (
run_done.result()
if run_done is not None and run_done.done() and not run_done.cancelled()
else None
)
if isinstance(tool_calls, ToolCallsDecoder):
err: BaseException = (
resolved.error
if resolved is not None and resolved.error is not None
else RuntimeError("Tool call stream closed before terminal tool event.")
)
for handle in list(tool_calls._active.values()):
handle._fail(err)
for handle in registered_tool_calls:
self._unregister_active_tool_call(handle)
for stream in registered_message_streams:
self._unregister_active_message_stream(stream)
if isinstance(subgraphs, SubgraphsDecoder):
terminal_status: SubgraphStatus = (
"failed"
if isinstance(resolved, _RunTerminal) and resolved.status == "errored"
else "completed"
)
for child in subgraphs._active.values():
if child.status == "started":
child._finish(terminal_status)
async def _subscription_iter(
self, params: SubscribeParams
) -> AsyncGenerator[Event, None]:
+246 -258
View File
@@ -17,13 +17,23 @@ import queue
import threading
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from typing import Any, Literal, TypedDict
from typing import Any, Literal, TypedDict, cast
from langchain_core.language_models.chat_model_stream import ChatModelStream
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import QueryParamTypes
from langgraph_sdk.stream.decoders import (
DataDecoder,
Decoder,
ExtensionsDecoder,
MessagesDecoder,
SubgraphsDecoder,
ToolCallsDecoder,
validate_interleave_channels,
)
from langgraph_sdk.stream.subscription import compute_union_filter, infer_channel
from langgraph_sdk.stream.sync_controller import SyncStreamController, _SyncSubscription
from langgraph_sdk.stream.transport import (
SyncEventStreamHandle,
@@ -295,6 +305,7 @@ class _SyncValuesProjection:
raise RuntimeError("SyncThreadStream not entered — use `with`.")
params: SubscribeParams = {"channels": ["values"]}
sub = self._thread._register_subscription(params)
decoder = DataDecoder("values")
try:
self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
@@ -304,12 +315,7 @@ class _SyncValuesProjection:
item = sub.queue.get()
if item is None:
return
params_field = item.get("params") or {}
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if data is not None:
yield data
yield from decoder.feed(cast(dict[str, Any], item))
finally:
self._thread._unregister_subscription(sub.id)
@@ -340,87 +346,35 @@ class _SyncMessagesProjection:
return
params = _exact_namespace_params(["messages"], self._namespace)
sub = self._thread._register_subscription(params)
active: dict[str, ChatModelStream] = {}
decoder = MessagesDecoder(
namespace=self._namespace,
stream_factory=lambda *, namespace, node, message_id: ChatModelStream(
namespace=namespace, node=node, message_id=message_id
),
)
registered: list[ChatModelStream] = []
pending: list[ChatModelStream] = []
try:
self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
while True:
item = sub.queue.get()
if item is None:
# EOF: surface remaining streams (possibly incomplete) in start order.
while pending:
yield pending.pop(0)
return
params_field = item.get("params") or {}
if _event_namespace(params_field) != self._namespace:
continue
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if not isinstance(data, dict):
continue
event_type = data.get("event")
if event_type == "message-start":
message_id = _message_event_id(data)
key = _message_route_key(data, fallback=message_id)
metadata = (
data.get("metadata")
if isinstance(data.get("metadata"), dict)
else {}
)
stream = ChatModelStream(
namespace=list(self._namespace),
node=metadata.get("langgraph_node") if metadata else None,
message_id=message_id,
)
active[key] = stream
for stream in decoder.feed(cast(dict[str, Any], item)):
self._thread._register_active_message_stream(stream)
stream.dispatch(data)
# Pre-dispatch all remaining events for this message so the
# caller can access str(message.text) inside a for loop.
while not stream._done:
next_item = sub.queue.get()
if next_item is None:
sub.queue.put(None)
break
next_params = next_item.get("params") or {}
next_data = (
next_params.get("data")
if isinstance(next_params, dict)
else None
)
if not isinstance(next_data, dict):
continue
next_event_type = next_data.get("event")
next_key = _message_route_key(next_data)
target = active.get(next_key)
if (
target is None
and next_key == "__single__"
and len(active) == 1
):
target = next(iter(active.values()))
if target is not None:
target.dispatch(next_data)
if next_event_type in ("message-finish", "error"):
self._thread._unregister_active_message_stream(target)
for rk, cand in list(active.items()):
if cand is target:
del active[rk]
yield stream
else:
key = _message_route_key(data)
stream = active.get(key)
if stream is None and key == "__single__" and len(active) == 1:
stream = next(iter(active.values()))
if stream is None:
continue
stream.dispatch(data)
if event_type in ("message-finish", "error"):
self._thread._unregister_active_message_stream(stream)
for route_key, candidate in list(active.items()):
if candidate is stream:
del active[route_key]
registered.append(stream)
pending.append(stream)
# Surface in start order once each (and all earlier) streams are done,
# so `str(stream.text)` is ready on yield (sync pre-dispatch contract).
while pending and pending[0]._done:
yield pending.pop(0)
finally:
for s in active.values():
self._thread._unregister_active_message_stream(s)
for stream in registered:
self._thread._unregister_active_message_stream(stream)
self._thread._unregister_subscription(sub.id)
@@ -595,100 +549,37 @@ class _SyncToolCallsProjection:
raise RuntimeError("SyncThreadStream not entered — use `with`.")
params = _exact_namespace_params(["tools"], self._namespace)
sub = self._thread._register_subscription(params)
active: dict[str, SyncToolCallHandle] = {}
decoder = ToolCallsDecoder(
namespace=self._namespace,
handle_factory=lambda *, tool_call_id, name, input, namespace: (
SyncToolCallHandle(
tool_call_id=tool_call_id,
name=name,
input=input,
namespace=namespace,
)
),
)
registered: list[SyncToolCallHandle] = []
pending: list[SyncToolCallHandle] = []
try:
self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
while True:
item = sub.queue.get()
if item is None:
# EOF: surface remaining handles (possibly incomplete) in start order.
while pending:
yield pending.pop(0)
return
params_field = item.get("params") or {}
if _event_namespace(params_field) != self._namespace:
continue
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if not isinstance(data, dict):
continue
event_type = data.get("event")
tool_call_id = data.get("tool_call_id")
if not isinstance(tool_call_id, str):
continue
if event_type == "tool-started":
tool_name = data.get("tool_name")
if not isinstance(tool_name, str):
tool_name = ""
handle = SyncToolCallHandle(
tool_call_id=tool_call_id,
name=tool_name,
input=data.get("input"),
namespace=list(self._namespace),
)
active[tool_call_id] = handle
for handle in decoder.feed(cast(dict[str, Any], item)):
self._thread._register_active_tool_call(handle)
# Pre-dispatch events until this tool call completes so that
# `call.output` is resolved when the caller receives the handle.
while not handle.done:
next_item = sub.queue.get()
if next_item is None:
sub.queue.put(None)
break
next_params = next_item.get("params") or {}
if _event_namespace(next_params) != self._namespace:
continue
next_data = (
next_params.get("data")
if isinstance(next_params, dict)
else None
)
if not isinstance(next_data, dict):
continue
next_event_type = next_data.get("event")
next_tcid = next_data.get("tool_call_id")
if not isinstance(next_tcid, str):
continue
if next_event_type == "tool-output-delta":
h = active.get(next_tcid)
delta = next_data.get("delta")
if h is not None and isinstance(delta, str):
h._push_delta(delta)
elif next_event_type == "tool-finished":
h = active.pop(next_tcid, None)
if h is not None:
self._thread._unregister_active_tool_call(h)
h._finish(next_data.get("output"))
elif next_event_type == "tool-error":
h = active.pop(next_tcid, None)
if h is not None:
self._thread._unregister_active_tool_call(h)
message = next_data.get("message")
h._fail(
RuntimeError(
str(message) if message else "Tool call errored"
)
)
yield handle
elif event_type == "tool-output-delta":
h = active.get(tool_call_id)
delta = data.get("delta")
if h is not None and isinstance(delta, str):
h._push_delta(delta)
elif event_type == "tool-finished":
h = active.pop(tool_call_id, None)
if h is not None:
self._thread._unregister_active_tool_call(h)
h._finish(data.get("output"))
elif event_type == "tool-error":
h = active.pop(tool_call_id, None)
if h is not None:
self._thread._unregister_active_tool_call(h)
message = data.get("message")
h._fail(
RuntimeError(
str(message) if message else "Tool call errored"
)
)
registered.append(handle)
pending.append(handle)
# Surface in start order once each (and all earlier) handles are done,
# so `call.output` is resolved when the caller receives the handle.
while pending and pending[0].done:
yield pending.pop(0)
finally:
# Read terminal error from _run_done if it is already resolved.
# We do NOT block here: callers who need a terminal observation
@@ -708,9 +599,10 @@ class _SyncToolCallsProjection:
if terminal_err is not None
else RuntimeError("Tool call stream closed before terminal tool event.")
)
for h in active.values():
self._thread._unregister_active_tool_call(h)
h._fail(err)
for handle in list(decoder._active.values()):
handle._fail(err)
for handle in registered:
self._thread._unregister_active_tool_call(handle)
self._thread._unregister_subscription(sub.id)
@@ -1067,8 +959,17 @@ class _SyncSubgraphsProjection:
raise RuntimeError("SyncThreadStream not entered — use `with`.")
params = _subgraph_subscription_params(self._scope)
sub = self._thread._register_subscription(params)
seen: set[tuple[str, ...]] = set()
active: dict[tuple[str, ...], SyncScopedStreamHandle] = {}
decoder = SubgraphsDecoder(
scope=self._scope,
handle_factory=lambda *, path, graph_name, trigger_call_id: (
SyncScopedStreamHandle(
thread=self._thread,
path=path,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
)
),
)
root_inbox: queue.Queue[Event | None] | None = (
self._thread._activate_root_messages_inbox() if not self._scope else None
)
@@ -1080,71 +981,14 @@ class _SyncSubgraphsProjection:
if item is None:
return
params_field = item.get("params") or {}
namespace = _event_namespace(params_field)
data = (
params_field.get("data") if isinstance(params_field, dict) else None
)
if not isinstance(data, dict):
continue
method = item.get("method")
ns_tuple = tuple(namespace)
routed_to_child = False
for child_path, child_handle in active.items():
child_len = len(child_path)
if (
len(ns_tuple) >= child_len
and ns_tuple[:child_len] == child_path
):
child_handle._push_event(item)
routed_to_child = True
break
if (
not routed_to_child
and root_inbox is not None
and method == "messages"
and tuple(namespace) == self._scope
root_inbox is not None
and item.get("method") == "messages"
and tuple(_event_namespace(params_field)) == self._scope
):
root_inbox.put_nowait(item)
if method == "tasks":
if "result" in data:
self._apply_tasks_result(namespace, data, active)
elif _is_direct_child(namespace, self._scope):
path = tuple(namespace)
if path not in seen:
seen.add(path)
graph_name, trigger_call_id = _parse_namespace_segment(
path[-1]
)
handle = SyncScopedStreamHandle(
thread=self._thread,
path=path,
graph_name=graph_name or None,
trigger_call_id=trigger_call_id,
)
active[path] = handle
yield handle
elif (
method == "lifecycle"
and data.get("event") == "started"
and _is_direct_child(namespace, self._scope)
):
# ``create_deep_agent`` subagent discovery: child-
# namespace ``lifecycle: started`` rather than ``tasks``.
path = tuple(namespace)
if path not in seen:
seen.add(path)
graph_name, trigger_call_id = _parse_namespace_segment(path[-1])
handle = SyncScopedStreamHandle(
thread=self._thread,
path=path,
graph_name=graph_name or None,
trigger_call_id=trigger_call_id,
)
active[path] = handle
yield handle
for handle in decoder.feed(cast(dict[str, Any], item)):
yield handle
finally:
# Determine terminal status from the run's lifecycle result.
# If _run_done resolved as errored, force-complete remaining children
@@ -1158,32 +1002,13 @@ class _SyncSubgraphsProjection:
terminal_status = "failed"
except Exception:
pass
for handle in active.values():
for handle in decoder._active.values():
if handle.status == "started":
handle._finish(terminal_status)
self._thread._unregister_subscription(sub.id)
if root_inbox is not None:
root_inbox.put_nowait(None)
def _apply_tasks_result(
self,
namespace: list[str],
data: dict[str, Any],
active: dict[tuple[str, ...], SyncScopedStreamHandle],
) -> None:
result_id = data.get("id")
if not result_id:
return
parent_path = tuple(namespace)
for child_path, handle in list(active.items()):
if child_path[:-1] != parent_path:
continue
if handle.trigger_call_id != result_id:
continue
status, error = _terminal_from_tasks_result(data)
handle._finish(status, error)
del active[child_path]
class _SyncExtensionsProjection:
"""Mapping from extension name to custom event payload stream.
@@ -1230,6 +1055,7 @@ class _SyncExtensionProjection:
if self._namespace:
params["namespaces"] = [self._namespace]
sub = self._thread._register_subscription(params)
decoder = ExtensionsDecoder(name=self._name)
try:
if self._thread._closed:
return
@@ -1239,12 +1065,7 @@ class _SyncExtensionProjection:
item = sub.queue.get()
if item is None:
return
event_params = item.get("params") or {}
data = (
event_params.get("data") if isinstance(event_params, dict) else None
)
if isinstance(data, dict):
yield data
yield from decoder.feed(cast(dict[str, Any], item))
finally:
self._thread._unregister_subscription(sub.id)
@@ -1461,6 +1282,173 @@ class SyncThreadStream:
params["depth"] = depth
return self._subscription_iter(params)
def interleave_projections(self, channels: list[str]) -> Iterator[tuple[str, Any]]:
"""Yield `(channel_name, item)` tuples across multiple projections.
One shared subscription drives all per-channel decoders; items arrive
in server-emit order (the SDK analog of `GraphRunStream.interleave`).
Args:
channels: Flat list of `"values"`, `"messages"`, `"tool_calls"`,
`"subgraphs"`, and/or extension names. Built-ins yield their
typed item (snapshot dict / `ChatModelStream` /
`SyncToolCallHandle` / `SyncScopedStreamHandle`); an extension
yields its payload dict, keyed by the bare extension name.
Note:
Handles and streams are yielded eagerly (before their sub-stream
completes), so items arrive interleaved in real time. To receive a
fully-resolved handle (output already populated), use the dedicated
`thread.tool_calls` / `thread.messages` projections instead.
"""
validate_interleave_channels(channels)
if self._transport is None:
raise RuntimeError("SyncThreadStream not entered — use `with`.")
decoders: dict[str, Decoder] = {}
sub_params: list[dict[str, Any]] = []
for ch in channels:
if ch == "values":
decoders[ch] = DataDecoder("values")
sub_params.append({"channels": ["values"]})
elif ch in ("updates", "checkpoints", "tasks"):
# Plain payload channels (local Updates/Checkpoints/Tasks
# analog). Root-scope filter is load-bearing: a co-requested
# unscoped `values` widens the merged subscription to all
# namespaces, so the decoder itself keeps subgraph payloads out.
decoders[ch] = DataDecoder(ch, namespace=[])
sub_params.append(dict(_exact_namespace_params([ch], [])))
elif ch == "messages":
decoders[ch] = MessagesDecoder(
namespace=[],
stream_factory=lambda *, namespace, node, message_id: (
ChatModelStream(
namespace=namespace, node=node, message_id=message_id
)
),
)
sub_params.append(dict(_exact_namespace_params(["messages"], [])))
elif ch == "tool_calls":
decoders[ch] = ToolCallsDecoder(
namespace=[],
handle_factory=lambda *, tool_call_id, name, input, namespace: (
SyncToolCallHandle(
tool_call_id=tool_call_id,
name=name,
input=input,
namespace=namespace,
)
),
)
sub_params.append(dict(_exact_namespace_params(["tools"], [])))
elif ch == "subgraphs":
decoders[ch] = SubgraphsDecoder(
scope=(),
handle_factory=lambda *, path, graph_name, trigger_call_id: (
SyncScopedStreamHandle(
thread=self,
path=path,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
)
),
)
sub_params.append(dict(_subgraph_subscription_params(())))
else:
decoders[ch] = ExtensionsDecoder(name=ch)
sub_params.append({"channels": [f"custom:{ch}"]})
if not sub_params:
return
merged = cast(
SubscribeParams,
compute_union_filter(cast("list[dict[str, Any]]", sub_params)),
)
subgraphs = decoders.get("subgraphs")
# Track decoder-created handles so teardown can finalize anything still
# in flight; otherwise an awaiting `handle.output` / `handle.messages`
# would block after an early break or run termination.
registered_tool_calls: list[SyncToolCallHandle] = []
registered_message_streams: list[ChatModelStream] = []
try:
for event in self._subscription_iter(merged):
if subgraphs is not None:
for item in subgraphs.feed(event):
yield ("subgraphs", item)
wire = infer_channel(event)
public = self._interleave_public_name(wire)
# subgraphs is driven separately above (it consumes all events); never dispatch it here.
if public is not None and public != "subgraphs":
decoder = decoders.get(public)
if decoder is not None:
for item in decoder.feed(event):
if public == "tool_calls":
self._register_active_tool_call(item)
registered_tool_calls.append(item)
elif public == "messages":
self._register_active_message_stream(item)
registered_message_streams.append(item)
yield (public, item)
finally:
self._finalize_interleave_decoders(
decoders.get("tool_calls"),
subgraphs,
registered_tool_calls,
registered_message_streams,
)
@staticmethod
def _interleave_public_name(wire: str | None) -> str | None:
"""Map a wire channel name to the public channel name used in interleave tuples."""
if wire is None:
return None
if wire == "tools":
return "tool_calls"
if wire.startswith("custom:"):
return wire[len("custom:") :]
return wire # values, messages (tasks/lifecycle pass through with no decoder match)
def _finalize_interleave_decoders(
self,
tool_calls: Decoder | None,
subgraphs: Decoder | None,
registered_tool_calls: list[SyncToolCallHandle],
registered_message_streams: list[ChatModelStream],
) -> None:
"""Finalize in-flight handles when `interleave_projections` tears down.
Mirrors the terminal handling of the dedicated `_SyncToolCallsProjection`
/ `_SyncSubgraphsProjection`: in-flight tool calls are failed (so a
blocking `handle.output` can't hang) and discovered subgraph children
are force-completed with the run's terminal status.
"""
run_done = self._run_done
resolved: _RunTerminal | None = None
if run_done is not None and run_done.done():
try:
resolved = run_done.result(timeout=0)
except Exception:
resolved = None
if isinstance(tool_calls, ToolCallsDecoder):
err: BaseException = (
resolved.error
if resolved is not None and resolved.error is not None
else RuntimeError("Tool call stream closed before terminal tool event.")
)
for handle in list(tool_calls._active.values()):
handle._fail(err)
for handle in registered_tool_calls:
self._unregister_active_tool_call(handle)
for stream in registered_message_streams:
self._unregister_active_message_stream(stream)
if isinstance(subgraphs, SubgraphsDecoder):
terminal_status: SubgraphStatus = (
"failed"
if resolved is not None and resolved.status == "errored"
else "completed"
)
for child in subgraphs._active.values():
if child.status == "started":
child._finish(terminal_status)
def _subscription_iter(self, params: SubscribeParams) -> Iterator[Event]:
sub = self._register_subscription(params)
try:
@@ -0,0 +1,359 @@
"""Per-channel event → items state machines.
Used both by the projection iterators (`_ValuesProjection`,
`_MessagesProjection`, `_ToolCallsProjection`, `_SubgraphsProjection`) on
`AsyncThreadStream` / `SyncThreadStream`, and by `interleave_projections`,
which drives multiple decoders from one shared subscription.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
from typing import Any, Literal, Protocol
#: Channel names the public ``interleave_projections`` API accepts as built-ins.
SUPPORTED_INTERLEAVE_CHANNELS = (
"values",
"messages",
"tool_calls",
"subgraphs",
"updates",
"checkpoints",
"tasks",
)
#: Channel names that ``infer_channel`` recognizes as first-class protocol
#: methods but that ``interleave_projections`` has no decoder for. Routing them
#: to the extension/``custom:`` fallback would subscribe to a channel that never
#: matches and silently yield nothing, so they are rejected up front (fail
#: closed). ``lifecycle`` is control-plane (drives run output/interrupt); ``tools``
#: is the wire alias for the public ``tool_calls`` channel.
RESERVED_INTERLEAVE_CHANNELS = frozenset({"lifecycle", "tools", "input"})
def validate_interleave_channels(channels: list[str]) -> None:
"""Reject reserved protocol channel names before they hit the fallback.
Genuine extension names pass through untouched; only names that
``infer_channel`` treats as built-in methods without an interleave decoder
are rejected, so a typo'd or unsupported protocol channel surfaces an error
instead of an empty stream.
"""
for ch in channels:
if ch in RESERVED_INTERLEAVE_CHANNELS:
hint = ' (use "tool_calls")' if ch == "tools" else ""
raise ValueError(
f"{ch!r} is not a valid interleave_projections channel{hint}. "
f"Supported channels: {', '.join(SUPPORTED_INTERLEAVE_CHANNELS)}, "
"or an extension name."
)
def _event_namespace(params_field: Any) -> list[str]:
if not isinstance(params_field, dict):
return []
namespace = params_field.get("namespace") or []
return list(namespace) if isinstance(namespace, list) else []
def _message_event_id(data: dict[str, Any]) -> str | None:
message_id = data.get("id") or data.get("message_id")
return str(message_id) if message_id is not None else None
def _message_route_key(data: dict[str, Any], fallback: str | None = None) -> str:
"""Return the routing key for a message-channel event in `active`.
Keys on `message_id` when available so concurrent messages that share the
same `run_id` (two AI turns in one agent step) route to independent streams
rather than colliding on a shared `run:<run_id>` slot.
"""
message_id = _message_event_id(data)
if message_id is not None:
return f"message:{message_id}"
if fallback is not None:
return f"message:{fallback}"
return "__single__"
SubgraphStatus = Literal["started", "completed", "failed", "interrupted"]
def _parse_namespace_segment(segment: str) -> tuple[str, str | None]:
name, sep, task_id = segment.partition(":")
return name, task_id if sep else None
def _terminal_from_tasks_result(
data: dict[str, Any],
) -> tuple[SubgraphStatus, str | None]:
if data.get("interrupts"):
return "interrupted", None
error = data.get("error")
if error:
return "failed", str(error)
return "completed", None
def _is_direct_child(namespace: list[str], scope: tuple[str, ...]) -> bool:
return len(namespace) == len(scope) + 1 and tuple(namespace[: len(scope)]) == scope
class Decoder(Protocol):
def feed(self, event: Mapping[str, Any]) -> Iterable[Any]: ...
class DataDecoder:
"""Yields `params.data` from events of a single `method`.
Covers the channels whose projection is just "emit the payload": `values`,
`updates`, `checkpoints`, `tasks` the SDK analog of local's
`Values`/`Updates`/`Checkpoints`/`TasksTransformer`, all of which push
`params["data"]` unchanged. The REST-state seeding for `values` stays at
the projection layer; it is a one-shot pre-stream fetch, not part of the
event state machine.
Args:
method: The protocol `method` this decoder consumes.
namespace: When not `None`, events whose namespace differs are ignored
(scope filter, mirroring the local transformers' `namespace != scope`
check). `None` consumes every namespace the historical `values`
projection behavior, where subscription scoping is handled upstream.
"""
def __init__(self, method: str, namespace: list[str] | None = None):
self._method = method
self._namespace = list(namespace) if namespace is not None else None
def feed(self, event: Mapping[str, Any]) -> Iterable[Any]:
if event.get("method") != self._method:
return
params = event.get("params") or {}
if self._namespace is not None and _event_namespace(params) != self._namespace:
return
data = params.get("data")
if data is not None:
yield data
class MessagesDecoder:
"""Yields one chat-model stream per `message-start` event.
Subsequent events route to the matching stream via `stream.dispatch(data)`.
Mirrors the per-event body of `_MessagesProjection._messages_iter`
(`_async/stream.py:404-458`). The subscription open/close and the
`_root_messages_inbox` drain branch stay at the projection layer.
Args:
namespace: Events whose namespace differs are ignored (scope filter).
stream_factory: Keyword-only `(namespace, node, message_id) -> stream`.
Sync binds `ChatModelStream`; async binds `AsyncChatModelStream`.
"""
def __init__(
self,
namespace: list[str],
stream_factory: Callable[..., Any],
):
self._namespace = list(namespace)
self._stream_factory = stream_factory
self._active: dict[str, Any] = {} # route_key -> stream
def feed(self, event: Mapping[str, Any]) -> Iterable[Any]:
if event.get("method") != "messages":
return
params = event.get("params") or {}
if _event_namespace(params) != self._namespace:
return
data = params.get("data")
if not isinstance(data, dict):
return
if data.get("event") == "message-start":
message_id = _message_event_id(data)
key = _message_route_key(data, fallback=message_id)
metadata = (
data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
)
stream = self._stream_factory(
namespace=list(self._namespace),
node=metadata.get("langgraph_node") if metadata else None,
message_id=message_id,
)
self._active[key] = stream
stream.dispatch(data)
yield stream
else:
key = _message_route_key(data)
stream = self._active.get(key)
if stream is None and key == "__single__" and len(self._active) == 1:
stream = next(iter(self._active.values()))
if stream is None:
return
stream.dispatch(data)
if data.get("event") in ("message-finish", "error"):
for route_key, candidate in list(self._active.items()):
if candidate is stream:
del self._active[route_key]
class ToolCallsDecoder:
"""Yields one tool-call handle per `tool-started` event.
Mirrors the per-event body of `_ToolCallsProjection._tool_calls_iter`
(`_async/stream.py:1168-1217`). The thread register/unregister and the
terminal-error-on-close finally stay at the projection / wrapper layer.
Args:
namespace: Events whose namespace differs are ignored.
handle_factory: Keyword-only `(tool_call_id, name, input, namespace) -> handle`.
"""
def __init__(self, namespace: list[str], handle_factory: Callable[..., Any]):
self._namespace = list(namespace)
self._handle_factory = handle_factory
self._active: dict[str, Any] = {}
def feed(self, event: Mapping[str, Any]) -> Iterable[Any]:
if event.get("method") != "tools":
return
params = event.get("params") or {}
if _event_namespace(params) != self._namespace:
return
data = params.get("data")
if not isinstance(data, dict):
return
tool_call_id = data.get("tool_call_id")
if not isinstance(tool_call_id, str):
return
event_type = data.get("event")
if event_type == "tool-started":
name = data.get("tool_name")
handle = self._handle_factory(
tool_call_id=tool_call_id,
name=name if isinstance(name, str) else "",
input=data.get("input"),
namespace=list(self._namespace),
)
self._active[tool_call_id] = handle
yield handle
elif event_type == "tool-output-delta":
handle = self._active.get(tool_call_id)
delta = data.get("delta")
if handle is not None and isinstance(delta, str):
handle._push_delta(delta)
elif event_type == "tool-finished":
handle = self._active.pop(tool_call_id, None)
if handle is not None:
handle._finish(data.get("output"))
elif event_type == "tool-error":
handle = self._active.pop(tool_call_id, None)
if handle is not None:
message = data.get("message")
handle._fail(
RuntimeError(str(message) if message else "Tool call errored")
)
class SubgraphsDecoder:
"""Discovers child subgraph handles and fans out events to active ones.
Mirrors the per-event body of `_SubgraphsProjection._subgraphs_iter`
(`_async/stream.py:963-1041`) plus `_apply_tasks_result`. Root-inbox
forwarding and terminal-status-on-close stay at the projection / wrapper
layer.
Args:
scope: Tuple-form namespace of this decoder's parent. `()` for root.
handle_factory: Keyword-only `(path, graph_name, trigger_call_id) -> handle`.
"""
def __init__(self, scope: tuple[str, ...], handle_factory: Callable[..., Any]):
self._scope = scope
self._handle_factory = handle_factory
self._active: dict[tuple[str, ...], Any] = {}
self._seen: set[tuple[str, ...]] = set()
def feed(self, event: Mapping[str, Any]) -> Iterable[Any]:
params = event.get("params") or {}
namespace = _event_namespace(params)
data = params.get("data")
if not isinstance(data, dict):
return
method = event.get("method")
# 1. Fanout: first active child whose path prefixes this namespace.
ns_tuple = tuple(namespace)
for child_path, child_handle in self._active.items():
child_len = len(child_path)
if len(ns_tuple) >= child_len and ns_tuple[:child_len] == child_path:
child_handle._push_event(event)
break
# 2 + 3. Discovery / status from tasks; discovery from lifecycle.
if method == "tasks":
if "result" in data:
self._apply_tasks_result(namespace, data)
elif _is_direct_child(namespace, self._scope):
yield from self._discover(namespace)
elif (
method == "lifecycle"
and data.get("event") == "started"
and _is_direct_child(namespace, self._scope)
):
yield from self._discover(namespace)
def _discover(self, namespace: list[str]) -> Iterable[Any]:
path = tuple(namespace)
if path in self._seen:
return
self._seen.add(path)
graph_name, trigger_call_id = _parse_namespace_segment(path[-1])
handle = self._handle_factory(
path=path,
graph_name=graph_name or None,
trigger_call_id=trigger_call_id,
)
self._active[path] = handle
yield handle
def _apply_tasks_result(self, namespace: list[str], data: dict[str, Any]) -> None:
result_id = data.get("id")
if not result_id:
return
parent_path = tuple(namespace)
for child_path, handle in list(self._active.items()):
if child_path[:-1] != parent_path:
continue
if handle.trigger_call_id != result_id:
continue
status, error = _terminal_from_tasks_result(data)
handle._finish(status, error)
del self._active[child_path]
class ExtensionsDecoder:
"""Yields `params.data` from one named custom channel.
Mirrors `_ExtensionProjection._iter` (`_async/stream.py:1278-1299`), with
an added name filter so it can share one subscription in interleave.
Args:
name: The extension name. Only `custom` events whose `data["name"]`
matches are consumed.
"""
def __init__(self, name: str):
if not name:
raise ValueError("extension name must be non-empty.")
self._name = name
def feed(self, event: Mapping[str, Any]) -> Iterable[Any]:
if event.get("method") != "custom":
return
params = event.get("params") or {}
data = params.get("data")
if not isinstance(data, dict):
return
if data.get("name") != self._name:
return
yield data
@@ -20,6 +20,7 @@ import httpx
import orjson
from langchain_protocol import Event
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.sse import BytesLineDecoder, SSEDecoder
from langgraph_sdk.stream.transport.base import (
EventStreamHandle,
@@ -49,8 +50,12 @@ class ProtocolSseTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_url = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_url = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._max_queue_size = max_queue_size
self._closed = False
@@ -10,6 +10,7 @@ import httpx
import orjson
from langchain_protocol import Event
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.sse import BytesLineDecoder, SSEDecoder
from langgraph_sdk.stream.transport.base import (
SyncEventStreamHandle,
@@ -31,8 +32,12 @@ class SyncProtocolSseTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_url = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_url = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._closed = False
self._open_responses: list[httpx.Response] = []
@@ -11,6 +11,7 @@ import orjson
from langchain_protocol import Event
from websockets.sync.client import connect as websocket_connect
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.stream.transport.base import (
SyncEventStreamHandle,
build_event_stream_body,
@@ -36,8 +37,12 @@ class SyncProtocolWebSocketTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_path = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_path = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._connect = connect
self._ping_interval = ping_interval
@@ -13,6 +13,7 @@ from langchain_protocol import Event
from websockets.asyncio.client import connect as websocket_connect
from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.stream.transport.base import (
EventStreamHandle,
build_event_stream_body,
@@ -39,8 +40,12 @@ class ProtocolWebSocketTransport:
) -> None:
self._client = client
self.thread_id = thread_id
self._commands_url = commands_path or f"/threads/{thread_id}/commands"
self._stream_path = stream_path or f"/threads/{thread_id}/stream/events"
self._commands_url = (
commands_path or f"/threads/{_quote_path_param(thread_id)}/commands"
)
self._stream_path = (
stream_path or f"/threads/{_quote_path_param(thread_id)}/stream/events"
)
self._default_headers: dict[str, str] = dict(headers or {})
self._connect = connect
self._max_queue_size = max_queue_size
@@ -0,0 +1,133 @@
"""Integration tests for RemoteGraph v3 streaming.
Tests the end-to-end wiring: RemoteGraph -> langgraph_sdk client.threads.stream(...) ->
docker-running langgraph-api -> SSE projections -> adapter classes.
Run with: pytest tests/integration/test_remote_graph_v3.py -m integration
"""
from __future__ import annotations
import uuid
import pytest
from langchain_core.runnables import RunnableConfig
from langgraph.pregel.remote import RemoteGraph
from langgraph.types import Command
pytestmark = pytest.mark.integration
URL = "http://localhost:2024"
# Input shapes matching what the integration graphs expect.
# `agent` graph (streaming_graph.py): AgentState has messages, value, items.
# `tools_agent` graph (tools_agent.py): create_agent graph expects messages list.
_AGENT_INPUT = {"messages": [], "value": "init", "items": []}
_TOOLS_AGENT_INPUT = {"messages": [{"role": "user", "content": "search for v3"}]}
@pytest.fixture
def remote_agent() -> RemoteGraph:
return RemoteGraph("agent", url=URL)
@pytest.fixture
def remote_tools_agent() -> RemoteGraph:
return RemoteGraph("tools_agent", url=URL)
async def test_async_happy_path_yields_output(remote_tools_agent: RemoteGraph) -> None:
"""tools_agent completes without interrupt; ``await stream.output`` drives
the run to terminal via the lifecycle watcher (no explicit event iteration
needed the SSE subscription stays open by design after run completion)."""
async with await remote_tools_agent.astream_events(
_TOOLS_AGENT_INPUT,
version="v3",
) as stream:
output = await stream.output()
assert output is not None
assert (await stream.interrupted()) is False
async def test_async_interrupt_path_surfaces_interrupts(
remote_agent: RemoteGraph,
) -> None:
"""agent graph hits ask_human; interrupted must be True with >= 1 interrupt.
Note: interrupts pause the run but DON'T resolve `_run_done` (only
`completed` / `failed` lifecycle phases do), so `await stream.output()`
would hang. The adapter doesn't expose `interleave()` on the async
side (mirrors local `AsyncGraphRunStream`), so drain the `values`
projection directly until the run reports it is interrupted.
"""
async with await remote_agent.astream_events(
_AGENT_INPUT,
version="v3",
) as stream:
async for _ in stream.values:
if await stream.interrupted():
break
assert (await stream.interrupted()) is True
interrupts = await stream.interrupts()
assert len(interrupts) >= 1
async def test_async_resume_after_interrupt(remote_agent: RemoteGraph) -> None:
"""Interrupt the agent at ask_human, then resume the SAME thread with
`Command(resume=...)`.
Validates the v3 resume path end-to-end. The client sends the raw resume
value as `input` (not a serialized Command); the server detects the
thread's pending interrupt from persisted state — which survives the first
session's close — and wraps it as `Command(resume=...)`, driving the run
past `ask_human` to completion (the graph interrupts only once).
"""
thread_id = str(uuid.uuid4())
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
# First session: drive until the agent pauses at the ask_human interrupt.
async with await remote_agent.astream_events(
_AGENT_INPUT,
config=config,
version="v3",
) as stream:
async for _ in stream.values:
if await stream.interrupted():
break
assert (await stream.interrupted()) is True
# Second session on the same thread: resume with the human's answer. The
# run continues past ask_human to completion with no further interrupt.
async with await remote_agent.astream_events(
Command(resume="yes"),
config=config,
version="v3",
) as stream:
output = await stream.output()
assert output is not None
assert (await stream.interrupted()) is False
def test_sync_happy_path_yields_output(remote_tools_agent: RemoteGraph) -> None:
"""Sync stream: tools_agent completes; ``stream.output`` (sync property)
blocks until terminal."""
with remote_tools_agent.stream_events(
_TOOLS_AGENT_INPUT,
version="v3",
) as stream:
output = stream.output
assert output is not None
assert stream.interrupted is False
async def test_abort_mid_run_cancels_server_side(
remote_tools_agent: RemoteGraph,
) -> None:
"""Abort immediately after run.start; reaching the end without exception
confirms abort + __aexit__ cleanup worked."""
async with await remote_tools_agent.astream_events(
_TOOLS_AGENT_INPUT,
version="v3",
) as stream:
await stream.abort()
# Reaching here without unhandled exceptions confirms abort + __aexit__ succeeded.
+12
View File
@@ -75,6 +75,18 @@ def values_event(
return _base(seq, "values", namespace or [], data or {"values": {}})
def updates_event(
seq: int = 0, namespace: list[str] | None = None, **data: Any
) -> dict[str, Any]:
return _base(seq, "updates", namespace or [], data or {})
def checkpoints_event(
seq: int = 0, namespace: list[str] | None = None, **data: Any
) -> dict[str, Any]:
return _base(seq, "checkpoints", namespace or [], data or {})
def custom_event(
seq: int = 0, name: str = "ext", namespace: list[str] | None = None, **data: Any
) -> dict[str, Any]:
@@ -0,0 +1,460 @@
"""Unit tests for the per-channel Decoders.
Each test drives a single decoder with synthetic events from `_events` and
asserts the items the decoder yields.
"""
from __future__ import annotations
from typing import Any
from langgraph_sdk.stream.decoders import (
DataDecoder,
ExtensionsDecoder,
MessagesDecoder,
SubgraphsDecoder,
ToolCallsDecoder,
)
from streaming._events import (
checkpoints_event,
custom_event,
lifecycle_completed_event,
lifecycle_started_event,
message_error_event,
message_finish_event,
message_start_event,
message_text_delta_event,
tasks_result_event,
tasks_start_event,
tool_error_event,
tool_finished_event,
tool_output_delta_event,
tool_started_event,
updates_event,
values_event,
)
def test_data_decoder_yields_params_data():
decoder = DataDecoder("values")
assert list(decoder.feed(values_event(seq=1, x=1))) == [{"x": 1}]
assert list(decoder.feed(values_event(seq=2, x=2, y=3))) == [{"x": 2, "y": 3}]
def test_data_decoder_ignores_other_methods():
decoder = DataDecoder("values")
assert list(decoder.feed(lifecycle_completed_event(seq=1))) == []
assert list(decoder.feed(updates_event(seq=2, foo=1))) == []
def test_data_decoder_handles_updates_checkpoints_tasks_methods():
assert list(DataDecoder("updates").feed(updates_event(seq=1, node={"x": 1}))) == [
{"node": {"x": 1}}
]
assert list(
DataDecoder("checkpoints").feed(checkpoints_event(seq=2, ts="t", v=4))
) == [{"ts": "t", "v": 4}]
# tasks payloads pass through verbatim as data dicts
[item] = list(DataDecoder("tasks").feed(tasks_start_event(seq=3, task_id="a")))
assert item["id"] == "a"
def test_data_decoder_namespace_none_yields_regardless_of_namespace():
decoder = DataDecoder("checkpoints", namespace=None)
assert list(decoder.feed(checkpoints_event(seq=1, namespace=["child"], v=1))) == [
{"v": 1}
]
def test_data_decoder_namespace_filter_drops_non_matching_namespace():
decoder = DataDecoder("checkpoints", namespace=[])
# root-namespace event is yielded; child-namespace event is filtered out
assert list(decoder.feed(checkpoints_event(seq=1, v=1))) == [{"v": 1}]
assert list(decoder.feed(checkpoints_event(seq=2, namespace=["child"], v=2))) == []
class _FakeStream:
"""Stand-in for AsyncChatModelStream/ChatModelStream in decoder tests."""
def __init__(self, *, namespace, node, message_id):
self.namespace = namespace
self.node = node
self.message_id = message_id
self.dispatched: list[dict] = []
def dispatch(self, data):
self.dispatched.append(data)
def _factory(*, namespace, node, message_id):
return _FakeStream(namespace=namespace, node=node, message_id=message_id)
def test_messages_decoder_yields_stream_on_message_start():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
streams = list(
decoder.feed(message_start_event(seq=1, message_id="m-1", node="agent"))
)
assert len(streams) == 1
assert streams[0].message_id == "m-1"
assert streams[0].node == "agent"
# The start event is dispatched into the stream too (matches stream.py:432).
assert (
streams[0].dispatched and streams[0].dispatched[0]["event"] == "message-start"
)
def test_messages_decoder_dispatches_delta_to_active_stream():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
[stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1")))
delta = message_text_delta_event(seq=2, message_id="m-1", text="hi")
assert list(decoder.feed(delta)) == []
assert stream.dispatched[-1]["event"] == "content-block-delta"
def test_messages_decoder_finish_retires_stream():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
[stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1")))
list(decoder.feed(message_finish_event(seq=2, message_id="m-1")))
assert stream.dispatched[-1]["event"] == "message-finish"
assert all(s is not stream for s in decoder._active.values())
[again] = list(decoder.feed(message_start_event(seq=3, message_id="m-1")))
assert again is not stream
def test_messages_decoder_error_retires_stream():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
[stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1")))
list(decoder.feed(message_error_event(seq=2, message_id="m-1", message="boom")))
assert stream.dispatched[-1]["event"] == "error"
assert all(s is not stream for s in decoder._active.values())
def test_messages_decoder_single_fallback_routes_idless_events():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
[stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1")))
list(decoder.feed(message_text_delta_event(seq=2, text="x"))) # no message_id
assert stream.dispatched[-1]["event"] == "content-block-delta"
def test_messages_decoder_drops_idful_events_for_unknown_stream():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
[stream] = list(decoder.feed(message_start_event(seq=1, message_id="m-1")))
list(decoder.feed(message_text_delta_event(seq=2, message_id="ghost", text="x")))
assert all(d["event"] != "content-block-delta" for d in stream.dispatched)
def test_messages_decoder_ignores_other_namespaces():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
assert (
list(
decoder.feed(
message_start_event(seq=1, namespace=["child"], message_id="m-1")
)
)
== []
)
def test_messages_decoder_drops_idless_delta_when_multiple_active():
decoder = MessagesDecoder(namespace=[], stream_factory=_factory)
[a] = list(decoder.feed(message_start_event(seq=1, message_id="m-1")))
[b] = list(decoder.feed(message_start_event(seq=2, message_id="m-2")))
# id-less delta is ambiguous with two active streams -> dropped, routed to neither
list(decoder.feed(message_text_delta_event(seq=3, text="x")))
assert all(d["event"] != "content-block-delta" for d in a.dispatched)
assert all(d["event"] != "content-block-delta" for d in b.dispatched)
class _FakeToolHandle:
def __init__(self, *, tool_call_id, name, input, namespace):
self.tool_call_id = tool_call_id
self.name = name
self.input = input
self.namespace = namespace
self.deltas: list[str] = []
self.finished_output: Any = None
self.finished = False
self.error: BaseException | None = None
def _push_delta(self, delta):
self.deltas.append(delta)
def _finish(self, output):
self.finished = True
self.finished_output = output
def _fail(self, exc):
self.error = exc
def _tool_factory(*, tool_call_id, name, input, namespace):
return _FakeToolHandle(
tool_call_id=tool_call_id, name=name, input=input, namespace=namespace
)
def test_tool_calls_decoder_yields_handle_on_start():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[handle] = list(
decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search"))
)
assert handle.tool_call_id == "tc-1"
assert handle.name == "search"
def test_tool_calls_decoder_routes_delta_finish_and_error():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[h] = list(
decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search"))
)
list(decoder.feed(tool_output_delta_event(seq=2, tool_call_id="tc-1", delta="x")))
assert h.deltas == ["x"]
list(
decoder.feed(
tool_finished_event(seq=3, tool_call_id="tc-1", output={"ok": True})
)
)
assert h.finished and h.finished_output == {"ok": True}
decoder2 = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[h2] = list(
decoder2.feed(
tool_started_event(seq=1, tool_call_id="tc-2", tool_name="search")
)
)
list(decoder2.feed(tool_error_event(seq=2, tool_call_id="tc-2", message="boom")))
assert isinstance(h2.error, RuntimeError) and "boom" in str(h2.error)
def test_tool_calls_decoder_drops_events_for_unknown_id():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
assert (
list(
decoder.feed(
tool_output_delta_event(seq=1, tool_call_id="ghost", delta="x")
)
)
== []
)
def test_tool_calls_decoder_finish_and_error_retire_handle():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[h] = list(
decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search"))
)
list(decoder.feed(tool_finished_event(seq=2, tool_call_id="tc-1")))
# retired: a late delta for the same id is now dropped
list(
decoder.feed(tool_output_delta_event(seq=3, tool_call_id="tc-1", delta="late"))
)
assert h.deltas == []
def test_tool_calls_decoder_ignores_other_namespaces():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
assert (
list(
decoder.feed(
tool_started_event(seq=1, namespace=["child"], tool_call_id="tc-1")
)
)
== []
)
def test_tool_calls_decoder_error_retires_handle():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[h] = list(
decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search"))
)
list(decoder.feed(tool_error_event(seq=2, tool_call_id="tc-1", message="boom")))
# retired: a late delta for the same id is now dropped
list(
decoder.feed(tool_output_delta_event(seq=3, tool_call_id="tc-1", delta="late"))
)
assert h.deltas == []
def test_tool_calls_decoder_skips_non_str_tool_call_id():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
# Build a tools event whose data.tool_call_id is not a string.
bad = tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")
bad["params"]["data"]["tool_call_id"] = 123
assert list(decoder.feed(bad)) == []
def test_tool_calls_decoder_skips_non_str_delta():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[h] = list(
decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search"))
)
evt = tool_output_delta_event(seq=2, tool_call_id="tc-1", delta="x")
evt["params"]["data"]["delta"] = 123 # non-str delta must be ignored
list(decoder.feed(evt))
assert h.deltas == []
def test_tool_calls_decoder_defaults_missing_tool_name_to_empty():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
evt = tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search")
del evt["params"]["data"]["tool_name"] # absent tool_name -> handle.name == ""
[h] = list(decoder.feed(evt))
assert h.name == ""
def test_tool_calls_decoder_error_message_defaults_when_blank():
decoder = ToolCallsDecoder(namespace=[], handle_factory=_tool_factory)
[h] = list(
decoder.feed(tool_started_event(seq=1, tool_call_id="tc-1", tool_name="search"))
)
evt = tool_error_event(seq=2, tool_call_id="tc-1")
evt["params"]["data"]["message"] = "" # blank -> default message
list(decoder.feed(evt))
assert str(h.error) == "Tool call errored"
class _FakeScopedHandle:
def __init__(self, *, path, graph_name, trigger_call_id):
self.path = path
self.graph_name = graph_name
self.trigger_call_id = trigger_call_id
self.status = "started"
self.error = None
self.events: list[dict] = []
def _push_event(self, event):
self.events.append(event)
def _finish(self, status, error=None):
if self.status != "started":
return
self.status = status
self.error = error
def _scoped_factory(*, path, graph_name, trigger_call_id):
return _FakeScopedHandle(
path=path, graph_name=graph_name, trigger_call_id=trigger_call_id
)
def test_subgraphs_decoder_discovers_on_lifecycle_started_once():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(lifecycle_started_event(seq=1, namespace=["child"])))
assert h.path == ("child",)
assert list(decoder.feed(lifecycle_started_event(seq=2, namespace=["child"]))) == []
def test_subgraphs_decoder_discovers_on_tasks_start_without_result():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["child"])))
assert h.path == ("child",)
def test_subgraphs_decoder_parses_graph_name_and_trigger_from_segment():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"])))
assert h.graph_name == "agent"
assert h.trigger_call_id == "call-1"
def test_subgraphs_decoder_fans_out_events_to_active_child():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(lifecycle_started_event(seq=1, namespace=["child"])))
inner = message_start_event(seq=2, namespace=["child"], message_id="m")
assert list(decoder.feed(inner)) == []
assert inner in h.events # whole event pushed, not just data
def test_subgraphs_decoder_fans_out_grandchild_to_direct_child():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(lifecycle_started_event(seq=1, namespace=["child"])))
grand = message_start_event(seq=2, namespace=["child", "grand"], message_id="m")
list(decoder.feed(grand))
assert grand in h.events
def test_subgraphs_decoder_tasks_result_at_parent_finalizes_child():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
# child discovered with a colon segment -> trigger_call_id == "call-1"
[h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"])))
assert h.trigger_call_id == "call-1"
# the finalizing tasks-result is emitted at the PARENT (root) namespace,
# with id (task_id) matching the child's trigger_call_id
list(
decoder.feed(
tasks_result_event(seq=2, namespace=[], task_id="call-1", result={"ok": 1})
)
)
assert h.status == "completed"
# finalized + removed from active: later child-namespace events no longer fan out
later = message_start_event(seq=3, namespace=["agent:call-1"], message_id="m")
list(decoder.feed(later))
assert later not in h.events
def test_subgraphs_decoder_tasks_result_failed_status():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"])))
list(
decoder.feed(
tasks_result_event(seq=2, namespace=[], task_id="call-1", error="boom")
)
)
assert h.status == "failed"
assert h.error == "boom"
def test_subgraphs_decoder_tasks_result_interrupted_status():
decoder = SubgraphsDecoder(scope=(), handle_factory=_scoped_factory)
[h] = list(decoder.feed(tasks_start_event(seq=1, namespace=["agent:call-1"])))
list(
decoder.feed(
tasks_result_event(
seq=2, namespace=[], task_id="call-1", interrupts=[{"id": "i-1"}]
)
)
)
assert h.status == "interrupted"
def test_subgraphs_decoder_ignores_unrelated_and_scope_itself():
decoder = SubgraphsDecoder(scope=("root",), handle_factory=_scoped_factory)
# not a direct child of ("root",): wrong depth / wrong prefix
assert list(decoder.feed(lifecycle_started_event(seq=1, namespace=["other"]))) == []
# the scope's own namespace is not a discovery
assert list(decoder.feed(lifecycle_started_event(seq=2, namespace=["root"]))) == []
def test_extensions_decoder_yields_full_data_for_matching_name():
decoder = ExtensionsDecoder(name="foo")
# custom_event(name="foo", x=1) -> params.data = {"name": "foo", "x": 1}
assert list(decoder.feed(custom_event(seq=1, name="foo", x=1))) == [
{"name": "foo", "x": 1}
]
def test_extensions_decoder_ignores_other_extension_names():
decoder = ExtensionsDecoder(name="foo")
assert list(decoder.feed(custom_event(seq=1, name="bar", x=1))) == []
def test_extensions_decoder_ignores_non_custom_methods():
decoder = ExtensionsDecoder(name="foo")
assert list(decoder.feed(lifecycle_completed_event(seq=1))) == []
def test_extensions_decoder_ignores_non_dict_data():
decoder = ExtensionsDecoder(name="foo")
evt = custom_event(seq=1, name="foo", x=1)
evt["params"]["data"] = "not-a-dict"
assert list(decoder.feed(evt)) == []
def test_extensions_decoder_rejects_empty_name():
import pytest
with pytest.raises(ValueError):
ExtensionsDecoder(name="")
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import cast
from typing import Any, cast
import httpx
import pytest
@@ -562,3 +562,79 @@ def test_sync_tool_call_handle_deltas_single_consumer_guard():
# Second access: must raise immediately (before any iteration).
with pytest.raises(RuntimeError, match="single consumer"):
_ = handle.deltas
def test_sync_messages_subscription_pre_dispatches_before_yield():
"""Over a live subscription, str(stream.text) must work immediately on yield."""
fake = SyncFakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
message_start_event(seq=1, message_id="msg-1"),
message_text_delta_event(seq=2, text="hello", message_id="msg-1"),
message_text_finish_event(seq=3, text="hello", message_id="msg-1"),
message_finish_event(seq=4, message_id="msg-1"),
lifecycle_completed_event(seq=5),
]
)
fake.set_state({})
collected: list[str] = []
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
for stream in thread.messages:
collected.append(str(stream.text))
assert collected == ["hello"]
def test_sync_tool_calls_subscription_resolves_output_before_yield():
"""Over a live subscription, call.output is resolved when the handle is yielded."""
fake = SyncFakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"),
tool_finished_event(seq=2, tool_call_id="call-1", output={"ok": True}),
lifecycle_completed_event(seq=3),
]
)
fake.set_state({})
outputs: list[Any] = []
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
for call in thread.tool_calls:
outputs.append(call.output) # resolved (blocking) on yield
assert outputs == [{"ok": True}]
# ---------------------------------------------------------------------------
# Regression: interleaved-concurrent tool calls must BOTH surface
# ---------------------------------------------------------------------------
def test_sync_tool_calls_interleaved_concurrent_calls_both_surface():
"""Two tool calls whose events interleave must BOTH be yielded (regression:
the pre-decoder read-ahead silently dropped the second concurrent call)."""
fake = SyncFakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-a", tool_name="search"),
tool_started_event(seq=2, tool_call_id="call-b", tool_name="lookup"),
tool_finished_event(seq=3, tool_call_id="call-a", output={"a": 1}),
tool_finished_event(seq=4, tool_call_id="call-b", output={"b": 2}),
lifecycle_completed_event(seq=5),
]
)
fake.set_state({})
seen = []
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
for call in thread.tool_calls:
seen.append(call.tool_call_id)
assert sorted(seen) == ["call-a", "call-b"]
@@ -602,3 +602,310 @@ def test_v3_streaming_sync_surface_smoke():
assert tools_result[0].name == "search" # ty: ignore[unresolved-attribute]
assert results["progress"] == [{"name": "progress", "step": 1}]
assert final == {"final": True}
# ---------------------------------------------------------------------------
# interleave_projections tests
# ---------------------------------------------------------------------------
def test_interleave_projections_single_channel_values():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
fake.script(
[
lifecycle_started_event(seq=0),
values_event(seq=1, counter=1),
values_event(seq=2, counter=2),
lifecycle_completed_event(seq=3),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
items = []
for ch, item in thread.interleave_projections(["values"]):
items.append((ch, item))
assert ("values", {"counter": 1}) in items
assert ("values", {"counter": 2}) in items
assert all(ch == "values" for ch, _ in items)
def test_interleave_projections_values_and_messages_arrival_order():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
fake.script(
[
lifecycle_started_event(seq=0),
values_event(seq=1, counter=1),
message_start_event(seq=2, message_id="m-1"),
values_event(seq=3, counter=2),
message_finish_event(seq=4, message_id="m-1"),
lifecycle_completed_event(seq=5),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
order = []
for ch, _ in thread.interleave_projections(["values", "messages"]):
order.append(ch)
if len(order) >= 3:
break
assert order[:3] == ["values", "messages", "values"]
def test_interleave_projections_mixes_builtin_and_extension():
from streaming._events import (
custom_event,
lifecycle_completed_event,
lifecycle_started_event,
values_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
fake.script(
[
lifecycle_started_event(seq=0),
values_event(seq=1, counter=1),
custom_event(seq=2, name="foo", hello="world"),
lifecycle_completed_event(seq=3),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
items = []
for ch, item in thread.interleave_projections(["values", "foo"]):
items.append((ch, item))
assert ("values", {"counter": 1}) in items
assert ("foo", {"name": "foo", "hello": "world"}) in items
def test_interleave_projections_tool_calls_uses_public_name():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
tool_finished_event,
tool_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"),
tool_finished_event(seq=2, tool_call_id="call-1", output={"ok": True}),
lifecycle_completed_event(seq=3),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
names = []
handle = None
for ch, item in thread.interleave_projections(["tool_calls"]):
names.append(ch)
if handle is None:
handle = item
break
assert names == ["tool_calls"]
assert handle is not None
assert handle.tool_call_id == "call-1"
def test_interleave_projections_subgraphs_discovers_child():
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
fake.script(
[
lifecycle_started_event(seq=0),
lifecycle_started_event(seq=1, namespace=["child"]),
lifecycle_completed_event(seq=2),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
discovered = []
for ch, handle in thread.interleave_projections(["subgraphs"]):
discovered.append((ch, handle.path))
assert ("subgraphs", ("child",)) in discovered
def test_interleave_projections_inflight_tool_call_failed_on_break():
"""A tool handle held past an early break is failed in teardown, never left hanging."""
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
tool_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"),
# no tool-finished: the call is still in flight when the consumer breaks
lifecycle_completed_event(seq=2),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
handle = None
for _, item in thread.interleave_projections(["tool_calls"]):
handle = item
break
assert handle is not None
# Without teardown finalization this blocks forever; the bounded
# timeout turns a regression into a TimeoutError, not a RuntimeError.
with pytest.raises(RuntimeError):
handle._result.result(timeout=2)
def test_interleave_projections_inflight_subgraph_finished_on_terminal():
"""A discovered subgraph child with no terminal tasks-result is force-completed."""
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
fake.script(
[
lifecycle_started_event(seq=0),
lifecycle_started_event(seq=1, namespace=["child"]),
# no tasks-result for the child: it is still "started" at run end
lifecycle_completed_event(seq=2),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
child = None
for _, handle in thread.interleave_projections(["subgraphs"]):
child = handle
assert child is not None
assert child.status == "completed"
@pytest.mark.parametrize("channel", ["lifecycle", "tools", "input"])
def test_interleave_projections_rejects_reserved_channel(channel):
"""Reserved protocol channel names raise instead of silently no-op'ing.
`infer_channel` treats these as first-class methods, but they have no
interleave decoder, so routing them to the extension/`custom:` fallback
would subscribe to a channel that never matches and yield nothing. Fail
closed. (`updates`/`checkpoints`/`tasks` are supported and tested below.)
"""
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({})
fake.script([lifecycle_started_event(seq=0), lifecycle_completed_event(seq=1)])
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with (
threads.stream(thread_id="t-1", assistant_id="agent") as thread,
pytest.raises(ValueError, match=channel),
):
for _ in thread.interleave_projections([channel]):
pass
def test_interleave_projections_data_channels_yield_payloads():
"""`updates`/`checkpoints`/`tasks` yield their raw `params.data` payloads."""
from streaming._events import (
checkpoints_event,
lifecycle_completed_event,
lifecycle_started_event,
tasks_start_event,
updates_event,
)
fake = SyncFakeServer()
fake.set_state({})
fake.script(
[
lifecycle_started_event(seq=0),
updates_event(seq=1, node={"v": 1}),
checkpoints_event(seq=2, ts="t-0", v=4),
tasks_start_event(seq=3, task_id="task-9"),
lifecycle_completed_event(seq=4),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
items = list(
thread.interleave_projections(["updates", "checkpoints", "tasks"])
)
assert ("updates", {"node": {"v": 1}}) in items
assert ("checkpoints", {"ts": "t-0", "v": 4}) in items
assert any(ch == "tasks" and item.get("id") == "task-9" for ch, item in items)
def test_interleave_projections_data_channel_scoped_to_root_namespace():
"""A child-namespace checkpoint must not leak into a root interleave."""
from streaming._events import (
checkpoints_event,
lifecycle_completed_event,
lifecycle_started_event,
)
fake = SyncFakeServer()
fake.set_state({"counter": 0})
fake.script(
[
lifecycle_started_event(seq=0),
checkpoints_event(seq=1, namespace=["child"], scope="child"),
checkpoints_event(seq=2, scope="root"),
lifecycle_completed_event(seq=3),
]
)
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
thread.run.start(input={})
checkpoints = [
item
for ch, item in thread.interleave_projections(["values", "checkpoints"])
if ch == "checkpoints"
]
assert {"scope": "root"} in checkpoints
assert {"scope": "child"} not in checkpoints
@@ -17,9 +17,17 @@ from langgraph_sdk.stream.transport import (
ProtocolWebSocketTransport,
)
from streaming._events import (
checkpoints_event,
custom_event,
lifecycle_completed_event,
lifecycle_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
tasks_start_event,
tool_finished_event,
tool_started_event,
updates_event,
values_event,
)
from streaming._fake_server import FakeServer
@@ -971,3 +979,255 @@ async def test_v3_streaming_async_surface_smoke():
assert tool_calls[0].name == "search"
assert progress == [{"name": "progress", "step": 1}]
assert final == {"final": True}
async def test_interleave_projections_single_channel_values():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
values_event(seq=1, counter=1),
values_event(seq=2, counter=2),
lifecycle_completed_event(seq=3),
]
)
fake.set_state({"counter": 0})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
items = []
async for ch, item in thread.interleave_projections(["values"]):
items.append((ch, item))
assert ("values", {"counter": 1}) in items
assert ("values", {"counter": 2}) in items
assert all(ch == "values" for ch, _ in items)
async def test_interleave_projections_values_and_messages_arrival_order():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
values_event(seq=1, counter=1),
message_start_event(seq=2, message_id="m-1"),
values_event(seq=3, counter=2),
message_finish_event(seq=4, message_id="m-1"),
lifecycle_completed_event(seq=5),
]
)
fake.set_state({"counter": 0})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
order = []
async for ch, _ in thread.interleave_projections(["values", "messages"]):
order.append(ch)
if len(order) >= 3:
break
assert order[:3] == ["values", "messages", "values"]
async def test_interleave_projections_mixes_builtin_and_extension():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
values_event(seq=1, counter=1),
custom_event(seq=2, name="foo", hello="world"),
lifecycle_completed_event(seq=3),
]
)
fake.set_state({"counter": 0})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
items = []
async for ch, item in thread.interleave_projections(["values", "foo"]):
items.append((ch, item))
assert ("values", {"counter": 1}) in items
# extension payload is the whole params.data including "name"; tuple uses bare name "foo"
assert ("foo", {"name": "foo", "hello": "world"}) in items
async def test_interleave_projections_tool_calls_uses_public_name():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"),
tool_finished_event(seq=2, tool_call_id="call-1", output={"ok": True}),
lifecycle_completed_event(seq=3),
]
)
fake.set_state({})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
names = []
async for ch, item in thread.interleave_projections(["tool_calls"]):
names.append(ch)
assert item.tool_call_id == "call-1" # real ToolCallHandle
# tuple uses the PUBLIC name "tool_calls", never the wire name "tools"
assert names == ["tool_calls"]
async def test_interleave_projections_subgraphs_discovers_child():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
lifecycle_started_event(seq=1, namespace=["child"]),
lifecycle_completed_event(seq=2),
]
)
fake.set_state({})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
discovered = []
async for ch, handle in thread.interleave_projections(["subgraphs"]):
discovered.append((ch, handle.path))
assert ("subgraphs", ("child",)) in discovered
async def test_interleave_projections_inflight_tool_call_failed_on_break():
"""A tool handle held past an early break is failed in teardown, never left hanging."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1", tool_name="search"),
# no tool-finished: the call is still in flight when the consumer breaks
lifecycle_completed_event(seq=2),
]
)
fake.set_state({})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
handle = None
async for _, item in thread.interleave_projections(["tool_calls"]):
handle = item
break
assert handle is not None
# Without teardown finalization this would hang forever; wait_for
# turns a regression into a TimeoutError rather than a RuntimeError.
with pytest.raises(RuntimeError):
await asyncio.wait_for(handle.output, timeout=2)
async def test_interleave_projections_inflight_subgraph_finished_on_terminal():
"""A discovered subgraph child with no terminal tasks-result is force-completed."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
lifecycle_started_event(seq=1, namespace=["child"]),
# no tasks-result for the child: it is still "started" at run end
lifecycle_completed_event(seq=2),
]
)
fake.set_state({})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
child = None
async for _, handle in thread.interleave_projections(["subgraphs"]):
child = handle
assert child is not None
assert child.status == "completed"
@pytest.mark.parametrize("channel", ["lifecycle", "tools", "input"])
async def test_interleave_projections_rejects_reserved_channel(channel):
"""Reserved protocol channel names raise instead of silently no-op'ing.
`infer_channel` treats these as first-class methods, but they have no
interleave decoder, so routing them to the extension/`custom:` fallback
would subscribe to a channel that never matches and yield nothing. Fail
closed. (`updates`/`checkpoints`/`tasks` are supported and tested below.)
"""
fake = FakeServer()
fake.script([lifecycle_started_event(seq=0), lifecycle_completed_event(seq=1)])
fake.set_state({})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
with pytest.raises(ValueError, match=channel):
async for _ in thread.interleave_projections([channel]):
pass
async def test_interleave_projections_data_channels_yield_payloads():
"""`updates`/`checkpoints`/`tasks` yield their raw `params.data` payloads."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
updates_event(seq=1, node={"v": 1}),
checkpoints_event(seq=2, ts="t-0", v=4),
tasks_start_event(seq=3, task_id="task-9"),
lifecycle_completed_event(seq=4),
]
)
fake.set_state({})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
items = []
async for ch, item in thread.interleave_projections(
["updates", "checkpoints", "tasks"]
):
items.append((ch, item))
assert ("updates", {"node": {"v": 1}}) in items
assert ("checkpoints", {"ts": "t-0", "v": 4}) in items
assert any(ch == "tasks" and item.get("id") == "task-9" for ch, item in items)
async def test_interleave_projections_data_channel_scoped_to_root_namespace():
"""A child-namespace checkpoint must not leak into a root interleave.
`values` subscribes unscoped, so `compute_union_filter` widens the merged
subscription to all namespaces; the `DataDecoder` root filter is what keeps
a subgraph checkpoint out of the root projection (mirrors local scope).
"""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
checkpoints_event(seq=1, namespace=["child"], scope="child"),
checkpoints_event(seq=2, scope="root"),
lifecycle_completed_event(seq=3),
]
)
fake.set_state({"counter": 0})
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
checkpoints = []
async for ch, item in thread.interleave_projections(
["values", "checkpoints"]
):
if ch == "checkpoints":
checkpoints.append(item)
assert {"scope": "root"} in checkpoints
assert {"scope": "child"} not in checkpoints
@@ -0,0 +1,136 @@
"""Regression tests for #7953: v3 stream transports must percent-encode
`thread_id` in their default paths so a value containing reserved characters
or dot-segments stays an opaque identifier under `/threads/{thread_id}/...`
instead of being normalized into a different resource path by the HTTP stack.
"""
from __future__ import annotations
import httpx
import pytest
from langgraph_sdk.stream.transport.base import build_websocket_url
from langgraph_sdk.stream.transport.http import ProtocolSseTransport
from langgraph_sdk.stream.transport.sync_http import SyncProtocolSseTransport
from langgraph_sdk.stream.transport.sync_ws import SyncProtocolWebSocketTransport
from langgraph_sdk.stream.transport.ws import ProtocolWebSocketTransport
# A thread_id that escapes the /threads/ namespace if interpolated raw: an HTTP
# client collapses `/threads/../assistants/abc/...` to `/assistants/abc/...`.
TRAVERSAL_THREAD_ID = "../assistants/abc"
ENCODED_COMMANDS_PATH = "/threads/..%2Fassistants%2Fabc/commands"
ENCODED_STREAM_PATH = "/threads/..%2Fassistants%2Fabc/stream/events"
@pytest.mark.anyio
async def test_async_sse_default_paths_encode_thread_id():
transport = ProtocolSseTransport(
client=httpx.AsyncClient(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_url == ENCODED_STREAM_PATH
def test_sync_sse_default_paths_encode_thread_id():
transport = SyncProtocolSseTransport(
client=httpx.Client(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_url == ENCODED_STREAM_PATH
@pytest.mark.anyio
async def test_async_ws_default_paths_encode_thread_id():
transport = ProtocolWebSocketTransport(
client=httpx.AsyncClient(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_path == ENCODED_STREAM_PATH
def test_sync_ws_default_paths_encode_thread_id():
transport = SyncProtocolWebSocketTransport(
client=httpx.Client(), thread_id=TRAVERSAL_THREAD_ID
)
assert transport._commands_url == ENCODED_COMMANDS_PATH
assert transport._stream_path == ENCODED_STREAM_PATH
async def test_async_sse_wire_path_stays_under_threads_namespace():
"""The path that actually goes on the wire must not be normalized away."""
seen: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.url.raw_path.decode("ascii"))
return httpx.Response(202)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://example.com",
trust_env=False,
) as client:
transport = ProtocolSseTransport(client=client, thread_id=TRAVERSAL_THREAD_ID)
await transport.send_command({"id": 1, "method": "noop", "params": {}})
assert seen[0] == ENCODED_COMMANDS_PATH
def test_sync_sse_wire_path_stays_under_threads_namespace():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.url.raw_path.decode("ascii"))
return httpx.Response(202)
with httpx.Client(
transport=httpx.MockTransport(handler),
base_url="https://example.com",
trust_env=False,
) as client:
transport = SyncProtocolSseTransport(
client=client, thread_id=TRAVERSAL_THREAD_ID
)
transport.send_command({"id": 1, "method": "noop", "params": {}})
assert seen[0] == ENCODED_COMMANDS_PATH
@pytest.mark.anyio
async def test_async_ws_url_stays_under_threads_namespace():
transport = ProtocolWebSocketTransport(
client=httpx.AsyncClient(base_url="https://example.com/api"),
thread_id=TRAVERSAL_THREAD_ID,
)
url = build_websocket_url(transport._client.base_url, transport._stream_path)
assert url == "wss://example.com/api/threads/..%2Fassistants%2Fabc/stream/events"
def test_sync_ws_url_stays_under_threads_namespace():
transport = SyncProtocolWebSocketTransport(
client=httpx.Client(base_url="https://example.com/api"),
thread_id=TRAVERSAL_THREAD_ID,
)
url = build_websocket_url(transport._client.base_url, transport._stream_path)
assert url == "wss://example.com/api/threads/..%2Fassistants%2Fabc/stream/events"
@pytest.mark.anyio
async def test_explicit_path_overrides_are_left_untouched():
"""Callers passing explicit paths opt out of default encoding entirely."""
sse = ProtocolSseTransport(
client=httpx.AsyncClient(),
thread_id=TRAVERSAL_THREAD_ID,
commands_path="/custom/commands",
stream_path="/custom/events",
)
assert sse._commands_url == "/custom/commands"
assert sse._stream_url == "/custom/events"
ws = ProtocolWebSocketTransport(
client=httpx.AsyncClient(),
thread_id=TRAVERSAL_THREAD_ID,
commands_path="/custom/commands",
stream_path="/custom/events",
)
assert ws._commands_url == "/custom/commands"
assert ws._stream_path == "/custom/events"