mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-26 09:32:25 +02:00
## 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
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""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.
|