chore: import upstream snapshot with attribution
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Shared helpers for the v3 streaming integration scripts.
|
||||
|
||||
All scripts share the same expectations:
|
||||
|
||||
- A `langgraph-api` server is reachable at `BASE_URL` (default
|
||||
http://localhost:2024 — set by `docker-compose.yml`, which builds
|
||||
on `langchain/langgraph-api:latest-py3.12`).
|
||||
- The example graph (`integration/graph/streaming_graph.py:graph`) is
|
||||
registered under the assistant id `agent` (see `integration/langgraph.json`).
|
||||
|
||||
Each script imports `make_async_client()` / `make_sync_client()` from here
|
||||
to construct the v3 SDK client. Override `BASE_URL` via the
|
||||
`LANGGRAPH_INTEGRATION_URL` env var if you're running the API elsewhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph_sdk._async.threads import ThreadsClient as AsyncThreadsClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
|
||||
BASE_URL = os.environ.get("LANGGRAPH_INTEGRATION_URL", "http://localhost:2024")
|
||||
ASSISTANT_ID = "agent"
|
||||
|
||||
|
||||
def make_async_client() -> tuple[AsyncThreadsClient, httpx.AsyncClient]:
|
||||
"""Build an async ThreadsClient pointing at the integration API.
|
||||
|
||||
Returns the client and the underlying httpx client so callers can close
|
||||
it. Typical usage:
|
||||
|
||||
```python
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(...) as thread:
|
||||
...
|
||||
finally:
|
||||
await raw.aclose()
|
||||
```
|
||||
"""
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.threads import ThreadsClient
|
||||
|
||||
raw = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0)
|
||||
return ThreadsClient(HttpClient(raw)), raw
|
||||
|
||||
|
||||
def make_sync_client() -> tuple[SyncThreadsClient, httpx.Client]:
|
||||
"""Build a sync ThreadsClient pointing at the integration API."""
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.threads import SyncThreadsClient
|
||||
|
||||
raw = httpx.Client(base_url=BASE_URL, timeout=30.0)
|
||||
return SyncThreadsClient(SyncHttpClient(raw)), raw
|
||||
|
||||
|
||||
def header(title: str) -> None:
|
||||
"""Print a section header for script output."""
|
||||
bar = "=" * (len(title) + 4)
|
||||
print(f"\n{bar}\n {title}\n{bar}")
|
||||
|
||||
|
||||
def auto_respond_async(thread: Any, response: Any = "yes") -> asyncio.Task[None]:
|
||||
"""Spawn a background task that responds to the first interrupt and exits.
|
||||
|
||||
Opens a private `thread.values` subscription and drains it; the SDK's
|
||||
`_signal_paused` mechanism pushes the terminal sentinel into the
|
||||
iterator when `interrupted` flips True, so the loop exits at the
|
||||
interrupt. The auto-responder then calls `run.respond(...)` and the
|
||||
foreground iteration sees the rest of the run.
|
||||
|
||||
Returns the task so callers can `await` it before tearing down the
|
||||
stream (recommended) or cancel it.
|
||||
"""
|
||||
|
||||
async def _runner() -> None:
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
with contextlib.suppress(Exception):
|
||||
await thread.run.respond(response)
|
||||
|
||||
return asyncio.create_task(_runner())
|
||||
|
||||
|
||||
def auto_respond_sync(thread: Any, response: Any = "yes") -> threading.Thread:
|
||||
"""Sync analogue of `auto_respond_async`."""
|
||||
|
||||
def _runner() -> None:
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
if thread.interrupted:
|
||||
with contextlib.suppress(Exception):
|
||||
thread.run.respond(response)
|
||||
|
||||
t = threading.Thread(target=_runner, daemon=True, name="auto-respond")
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def check_api_reachable() -> None:
|
||||
"""Fail fast with a helpful message if the API isn't reachable.
|
||||
|
||||
Call this at the top of `main()` in each script.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(f"{BASE_URL}/ok", timeout=2.0)
|
||||
resp.raise_for_status()
|
||||
except Exception as err:
|
||||
raise SystemExit(
|
||||
f"\nCannot reach the integration API at {BASE_URL}: {err!r}\n"
|
||||
f"Did you run `docker compose up -d` from `libs/sdk-py/integration/`?\n"
|
||||
f"Or set LANGGRAPH_INTEGRATION_URL=... to point elsewhere.\n"
|
||||
) from err
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Exercise mid-run cancellation against the integration API.
|
||||
|
||||
Strategy: start a run on a fresh thread, capture the run id, then
|
||||
cancel via the runs REST client while events are still flowing. The
|
||||
projection iterator must terminate without hanging, no exception
|
||||
should escape, and the thread's persisted status must reflect a
|
||||
non-success terminal state.
|
||||
|
||||
The graph normally interrupts at `ask_human`; cancel must take effect
|
||||
before or after that interrupt, and either way the run must end up in
|
||||
a non-success state from the server's perspective.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
_CANCEL_GRACE_SECONDS = 10.0
|
||||
|
||||
|
||||
async def _cancel_after_first_event(
|
||||
runs_client: Any,
|
||||
thread_id: str,
|
||||
run_id_future: asyncio.Future[str],
|
||||
) -> None:
|
||||
"""Wait for the run id, briefly let events flow, then cancel."""
|
||||
run_id = await run_id_future
|
||||
# Allow a beat of events to flow so cancel hits mid-stream rather
|
||||
# than racing with the run.start handshake.
|
||||
await asyncio.sleep(0.1)
|
||||
with contextlib.suppress(Exception):
|
||||
await runs_client.cancel(thread_id, run_id, wait=False)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async mid-run cancel")
|
||||
threads, raw = make_async_client()
|
||||
# Cancel goes through the runs REST surface, not the stream proxy.
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._async.runs import RunsClient
|
||||
|
||||
runs_client = RunsClient(HttpClient(raw))
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
run_id_future: asyncio.Future[str] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
start_result = await thread.run.start(
|
||||
input={"messages": [], "value": "init", "items": []}
|
||||
)
|
||||
run_id = start_result.get("run_id")
|
||||
assert run_id, f"run.start returned no run_id: {start_result!r}"
|
||||
run_id_future.set_result(run_id)
|
||||
|
||||
canceller = asyncio.create_task(
|
||||
_cancel_after_first_event(runs_client, thread.thread_id, run_id_future)
|
||||
)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
started = time.monotonic()
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if time.monotonic() - started > _CANCEL_GRACE_SECONDS:
|
||||
raise AssertionError(
|
||||
f"values iterator did not terminate within "
|
||||
f"{_CANCEL_GRACE_SECONDS}s of cancel"
|
||||
)
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
await canceller
|
||||
|
||||
persisted = await threads.get(thread.thread_id)
|
||||
status = persisted.get("status")
|
||||
print(f" snapshots before cancel: {len(snapshots)}")
|
||||
print(f" thread.thread_id={thread.thread_id}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
print(f" persisted status={status!r}")
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised after cancel: {iteration_error!r}"
|
||||
)
|
||||
assert status != "success", (
|
||||
f"expected non-success terminal status after cancel, got {status!r}"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def _cancel_after_first_event_sync(
|
||||
runs_client: Any,
|
||||
thread_id: str,
|
||||
run_id_event: threading.Event,
|
||||
run_id_holder: dict[str, str],
|
||||
) -> None:
|
||||
run_id_event.wait(timeout=10.0)
|
||||
run_id = run_id_holder.get("run_id")
|
||||
if not run_id:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
with contextlib.suppress(Exception):
|
||||
runs_client.cancel(thread_id, run_id, wait=False)
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync mid-run cancel")
|
||||
threads, raw = make_sync_client()
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk._sync.runs import SyncRunsClient
|
||||
|
||||
runs_client = SyncRunsClient(SyncHttpClient(raw))
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
run_id_event = threading.Event()
|
||||
run_id_holder: dict[str, str] = {}
|
||||
start_result = thread.run.start(
|
||||
input={"messages": [], "value": "init", "items": []}
|
||||
)
|
||||
run_id = start_result.get("run_id")
|
||||
assert run_id, f"run.start returned no run_id: {start_result!r}"
|
||||
run_id_holder["run_id"] = run_id
|
||||
run_id_event.set()
|
||||
|
||||
canceller = threading.Thread(
|
||||
target=_cancel_after_first_event_sync,
|
||||
args=(runs_client, thread.thread_id, run_id_event, run_id_holder),
|
||||
daemon=True,
|
||||
name="cancel-worker",
|
||||
)
|
||||
canceller.start()
|
||||
|
||||
snapshots: list[dict] = []
|
||||
started = time.monotonic()
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if time.monotonic() - started > _CANCEL_GRACE_SECONDS:
|
||||
raise AssertionError(
|
||||
f"values iterator did not terminate within "
|
||||
f"{_CANCEL_GRACE_SECONDS}s of cancel"
|
||||
)
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
canceller.join(timeout=5)
|
||||
|
||||
persisted = threads.get(thread.thread_id)
|
||||
status = persisted.get("status")
|
||||
print(f" snapshots before cancel: {len(snapshots)}")
|
||||
print(f" thread.thread_id={thread.thread_id}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
print(f" persisted status={status!r}")
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised after cancel: {iteration_error!r}"
|
||||
)
|
||||
assert status != "success", (
|
||||
f"expected non-success terminal status after cancel, got {status!r}"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Exercise concurrent `threads.stream()` against the integration API.
|
||||
|
||||
Two distinct threads.stream() contexts run in parallel against the same
|
||||
client. Each context is independent (different thread_id minted by the
|
||||
SDK, separate controller, separate auto-responder). Invariants:
|
||||
|
||||
1. Both runs reach the canonical terminal state independently
|
||||
(`items == ['streamed','tool','asked','sub']`).
|
||||
2. Their thread_ids differ (no thread-id collision when minting client-side).
|
||||
3. Neither raises during iteration.
|
||||
|
||||
This catches regressions where the two streams might share controller
|
||||
state or where minted ids could collide under concurrent ``__aenter__``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
_EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"]
|
||||
|
||||
|
||||
async def _drive_one_async(threads: Any, label: str) -> dict[str, Any]:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
responder = auto_respond_async(thread)
|
||||
# Just drain values until terminal; we only care about the final state.
|
||||
async for _ in thread.values:
|
||||
pass
|
||||
await responder
|
||||
final = await thread.output
|
||||
print(f" [{label}] thread_id={thread.thread_id} items={final.get('items')!r}")
|
||||
return {"thread_id": thread.thread_id, "items": final.get("items")}
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async concurrent threads.stream (x2)")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
_drive_one_async(threads, "A"),
|
||||
_drive_one_async(threads, "B"),
|
||||
)
|
||||
a, b = results
|
||||
assert a["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream A failed to reach terminal: {a!r}"
|
||||
)
|
||||
assert b["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream B failed to reach terminal: {b!r}"
|
||||
)
|
||||
assert a["thread_id"] != b["thread_id"], (
|
||||
f"concurrent streams collided on thread_id {a['thread_id']!r}"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def _drive_one_sync(
|
||||
threads: Any, label: str, results: dict[str, dict[str, Any]]
|
||||
) -> None:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
responder = auto_respond_sync(thread)
|
||||
for _ in thread.values:
|
||||
pass
|
||||
responder.join(timeout=10)
|
||||
final = thread.output
|
||||
print(f" [{label}] thread_id={thread.thread_id} items={final.get('items')!r}")
|
||||
results[label] = {"thread_id": thread.thread_id, "items": final.get("items")}
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync concurrent threads.stream (x2)")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
workers = [
|
||||
threading.Thread(
|
||||
target=_drive_one_sync,
|
||||
args=(threads, label, results),
|
||||
daemon=True,
|
||||
name=f"sync-stream-{label}",
|
||||
)
|
||||
for label in ("A", "B")
|
||||
]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(timeout=60)
|
||||
assert not w.is_alive(), f"worker {w.name} did not finish within 60s"
|
||||
|
||||
a = results.get("A")
|
||||
b = results.get("B")
|
||||
assert a is not None and a["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream A failed to reach terminal: {a!r}"
|
||||
)
|
||||
assert b is not None and b["items"] == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"stream B failed to reach terminal: {b!r}"
|
||||
)
|
||||
assert a["thread_id"] != b["thread_id"], (
|
||||
f"concurrent streams collided on thread_id {a['thread_id']!r}"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Exercise `thread.extensions[name]` against the integration API.
|
||||
|
||||
Every node in the example graph writes `("progress", {...})` via
|
||||
`get_stream_writer`. This script verifies the `extensions["progress"]`
|
||||
projection yields each progress event in order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async extensions[progress]")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
events: list[dict] = []
|
||||
async for event in thread.extensions["progress"]:
|
||||
print(f" progress: {event!r}")
|
||||
events.append(event)
|
||||
print(f" total progress events: {len(events)}")
|
||||
assert events, "expected at least one progress event"
|
||||
# Verify ordering covers the node sequence.
|
||||
steps = [e.get("step") for e in events]
|
||||
print(f" step sequence: {steps}")
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync extensions[progress]")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
events: list[dict] = []
|
||||
for event in thread.extensions["progress"]:
|
||||
print(f" progress: {event!r}")
|
||||
events.append(event)
|
||||
print(f" total progress events: {len(events)}")
|
||||
assert events, "expected at least one progress event"
|
||||
steps = [e.get("step") for e in events]
|
||||
print(f" step sequence: {steps}")
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Exercise helper methods on `thread` against the integration API.
|
||||
|
||||
Covers `thread.agent.get_tree(xray=...)` and the extensions cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async helpers (agent.get_tree, extensions cache)")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
tree = await thread.agent.get_tree()
|
||||
print(f" get_tree() returned: nodes={list(tree.get('nodes', []))[:5]} ...")
|
||||
assert tree, "expected non-empty tree"
|
||||
|
||||
tree_xray = await thread.agent.get_tree(xray=True)
|
||||
print(f" get_tree(xray=True) returned keys: {list(tree_xray)[:5]}")
|
||||
|
||||
# Extensions cache: same name returns same projection instance.
|
||||
a = thread.extensions["progress"]
|
||||
b = thread.extensions["progress"]
|
||||
assert a is b, "expected cached _ExtensionProjection on repeated access"
|
||||
print(" extensions cache: OK (same projection instance reused)")
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync helpers (agent.get_tree, extensions cache)")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
tree = thread.agent.get_tree()
|
||||
print(f" get_tree() returned: nodes={list(tree.get('nodes', []))[:5]} ...")
|
||||
assert tree, "expected non-empty tree"
|
||||
|
||||
tree_xray = thread.agent.get_tree(xray=True)
|
||||
print(f" get_tree(xray=True) returned keys: {list(tree_xray)[:5]}")
|
||||
|
||||
a = thread.extensions["progress"]
|
||||
b = thread.extensions["progress"]
|
||||
assert a is b, "expected cached _ExtensionProjection on repeated access"
|
||||
print(" extensions cache: OK (same projection instance reused)")
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Exercise lifecycle state + `thread.run.respond(...)` against the integration API.
|
||||
|
||||
The example graph's `ask_human` node calls `interrupt("Are we good?")`. This
|
||||
script:
|
||||
|
||||
1. Starts a run.
|
||||
2. Waits until `thread.interrupted` becomes True (an `input.requested`
|
||||
lifecycle event lands).
|
||||
3. Inspects `thread.interrupts` to see the outstanding payload.
|
||||
4. Calls `thread.run.respond("yes")` to resume.
|
||||
5. Awaits `thread.output` for the final state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async lifecycle + respond")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# Drain values until interrupt fires. (`thread.values` ends when
|
||||
# the run terminates OR when the run is paused on an interrupt;
|
||||
# the latter sets `thread.interrupted` mid-iteration.)
|
||||
saw_interrupt = False
|
||||
async for _snap in thread.values:
|
||||
if thread.interrupted:
|
||||
saw_interrupt = True
|
||||
break
|
||||
|
||||
print(f" thread.interrupted = {thread.interrupted}")
|
||||
print(f" thread.interrupts = {thread.interrupts!r}")
|
||||
assert thread.interrupted, "expected an interrupt before the run completed"
|
||||
assert thread.interrupts, "expected interrupts list to be populated"
|
||||
|
||||
await thread.run.respond("yes")
|
||||
|
||||
final = await thread.output
|
||||
print(f" final output items: {final.get('items')!r}")
|
||||
assert "asked" in final.get("items", []), (
|
||||
"expected ask_human to have run after respond"
|
||||
)
|
||||
print(f" saw_interrupt before respond = {saw_interrupt}")
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync lifecycle + respond")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
saw_interrupt = False
|
||||
for _snap in thread.values:
|
||||
if thread.interrupted:
|
||||
saw_interrupt = True
|
||||
break
|
||||
|
||||
print(f" thread.interrupted = {thread.interrupted}")
|
||||
print(f" thread.interrupts = {thread.interrupts!r}")
|
||||
assert thread.interrupted, "expected an interrupt before the run completed"
|
||||
assert thread.interrupts, "expected interrupts list to be populated"
|
||||
|
||||
thread.run.respond("yes")
|
||||
|
||||
final = thread.output
|
||||
print(f" final output items: {final.get('items')!r}")
|
||||
assert "asked" in final.get("items", []), (
|
||||
"expected ask_human to have run after respond"
|
||||
)
|
||||
print(f" saw_interrupt before respond = {saw_interrupt}")
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Exercise `thread.messages` against the integration API.
|
||||
|
||||
The ``stream_message`` node in ``streaming_graph`` invokes a fake
|
||||
``FakeMessagesListChatModel`` whose ``_stream`` callbacks drive
|
||||
langgraph's ``StreamMessagesHandlerV2`` -> ``MessagesTransformer``,
|
||||
so the v3 ``messages`` channel emits the normalized delta lifecycle
|
||||
(``message-start`` -> ``content-block-start`` ->
|
||||
``content-block-delta`` -> ``content-block-finish`` ->
|
||||
``message-finish``) at root namespace.
|
||||
|
||||
Pattern note: drain the outer iterator first (list comprehension)
|
||||
before consuming each handle's chunks -- the outer iterator yields
|
||||
on ``message-start`` but the inner ``chunk`` stream only completes
|
||||
when ``message-finish`` is processed by the outer iter. Iterating
|
||||
chunks while the outer is suspended at ``yield`` deadlocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async messages")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
# Graph interrupts at `ask_human`; background responder
|
||||
# unblocks so terminal lifecycle fires and the messages
|
||||
# iterator exits cleanly.
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
streams = [s async for s in thread.messages]
|
||||
await responder
|
||||
print(f" total streams: {len(streams)}")
|
||||
for stream in streams:
|
||||
text = "".join([t async for t in stream.text])
|
||||
msg_id = getattr(stream, "message_id", None) or "?"
|
||||
print(f" message {msg_id}: {text!r}")
|
||||
assert streams, "expected at least one streamed message"
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync messages")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
streams = list(thread.messages)
|
||||
responder.join(timeout=5)
|
||||
print(f" total streams: {len(streams)}")
|
||||
for stream in streams:
|
||||
text = "".join(list(stream.text))
|
||||
msg_id = getattr(stream, "message_id", None) or "?"
|
||||
print(f" message {msg_id}: {text!r}")
|
||||
assert streams, "expected at least one streamed message"
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Exercise stream-handle close + recovery against the integration API.
|
||||
|
||||
The SDK's "reconnect on transport drop" code path (controller
|
||||
`_reconnect_shared_stream`) only fires when `shared.done` resolves to a
|
||||
non-cancelled error — i.e. genuine network/server failures, not graceful
|
||||
client-initiated closes. Reliably faking such an error against a real
|
||||
server is brittle, so this script asserts the next-strongest invariant:
|
||||
**a client-initiated stream close mid-iteration must not corrupt durable
|
||||
state**.
|
||||
|
||||
Concretely:
|
||||
|
||||
1. Start the run; let the auto-responder unblock the interrupt.
|
||||
2. Drop the shared SSE handle after the first snapshot.
|
||||
3. The values projection iterator may end early (the close drains the
|
||||
sub queue with `None`), but `thread.output` must still resolve to the
|
||||
canonical terminal state via the REST fallback path.
|
||||
4. No exception escapes the iteration.
|
||||
|
||||
We also instrument `_dedup_iter` to count any duplicate event_ids and
|
||||
print the counter for visibility. A future regression that
|
||||
double-delivers events through the controller would surface here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
_EXPECTED_TERMINAL_ITEMS = ["streamed", "tool", "asked", "sub"]
|
||||
|
||||
|
||||
def _instrument_dedup_async(controller: Any) -> dict[str, int]:
|
||||
"""Wrap `_dedup_iter` so duplicate event_ids are counted."""
|
||||
counter = {"drops": 0, "yields": 0}
|
||||
original = controller._dedup_iter.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(original)
|
||||
async def _counted(self, source): # type: ignore[no-untyped-def]
|
||||
async for event in source:
|
||||
event_id = event.get("event_id")
|
||||
if event_id is not None:
|
||||
if event_id in self._seen_event_ids:
|
||||
counter["drops"] += 1
|
||||
continue
|
||||
self._seen_event_ids.add(event_id)
|
||||
counter["yields"] += 1
|
||||
yield event
|
||||
|
||||
controller._dedup_iter = _counted.__get__(controller, type(controller))
|
||||
return counter
|
||||
|
||||
|
||||
def _instrument_dedup_sync(controller: Any) -> dict[str, int]:
|
||||
counter = {"drops": 0, "yields": 0}
|
||||
original = controller._dedup_iter.__func__ # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(original)
|
||||
def _counted(self, source): # type: ignore[no-untyped-def]
|
||||
for event in source:
|
||||
event_id = event.get("event_id")
|
||||
if event_id is not None:
|
||||
if event_id in self._seen_event_ids:
|
||||
counter["drops"] += 1
|
||||
continue
|
||||
self._seen_event_ids.add(event_id)
|
||||
counter["yields"] += 1
|
||||
yield event
|
||||
|
||||
controller._dedup_iter = _counted.__get__(controller, type(controller))
|
||||
return counter
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async stream-close mid-iteration (terminal state via REST)")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
counter = _instrument_dedup_async(thread)
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
dropped = False
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if not dropped and thread._shared_stream is not None:
|
||||
print(f" dropping shared stream (cursor={thread._cursor})...")
|
||||
await thread._shared_stream.close()
|
||||
dropped = True
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
await responder
|
||||
|
||||
final = await thread.output
|
||||
print(f" snapshots seen before drop: {len(snapshots)}")
|
||||
print(f" final items={final.get('items')!r}")
|
||||
print(f" dedup drops={counter['drops']} yields={counter['yields']}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
|
||||
assert dropped, "expected to drop the shared stream during iteration"
|
||||
assert snapshots, "expected at least one snapshot before the drop"
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised on stream close: {iteration_error!r}"
|
||||
)
|
||||
assert final.get("items") == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"terminal state not reached via REST after drop: "
|
||||
f"items={final.get('items')!r}"
|
||||
)
|
||||
assert counter["drops"] == 0, (
|
||||
f"unexpected dedup activity (drops={counter['drops']}); "
|
||||
"no rotation occurred so no overlap was expected"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync stream-close mid-iteration (terminal state via REST)")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
controller = thread._controller
|
||||
counter = _instrument_dedup_sync(controller)
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
dropped = False
|
||||
iteration_error: BaseException | None = None
|
||||
try:
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
if (
|
||||
not dropped
|
||||
and controller is not None
|
||||
and controller._shared_stream is not None
|
||||
):
|
||||
print(
|
||||
f" dropping shared stream (cursor={controller._cursor})..."
|
||||
)
|
||||
controller._shared_stream.close()
|
||||
dropped = True
|
||||
except BaseException as err:
|
||||
iteration_error = err
|
||||
|
||||
responder.join(timeout=10)
|
||||
|
||||
final = thread.output
|
||||
print(f" snapshots seen before drop: {len(snapshots)}")
|
||||
print(f" final items={final.get('items')!r}")
|
||||
print(f" dedup drops={counter['drops']} yields={counter['yields']}")
|
||||
print(f" iteration_error={iteration_error!r}")
|
||||
|
||||
assert dropped, "expected to drop the shared stream during iteration"
|
||||
assert snapshots, "expected at least one snapshot before the drop"
|
||||
assert iteration_error is None, (
|
||||
f"values iterator raised on stream close: {iteration_error!r}"
|
||||
)
|
||||
assert final.get("items") == _EXPECTED_TERMINAL_ITEMS, (
|
||||
f"terminal state not reached via REST after drop: "
|
||||
f"items={final.get('items')!r}"
|
||||
)
|
||||
assert counter["drops"] == 0, (
|
||||
f"unexpected dedup activity (drops={counter['drops']}); "
|
||||
"no rotation occurred so no overlap was expected"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Exercise `thread.subgraphs` against both example graphs.
|
||||
|
||||
Two passes:
|
||||
|
||||
1. `agent` (plain `StateGraph`): the parent calls a nested subgraph via
|
||||
`subgraph.invoke(...)` from a node. This may or may not surface as a
|
||||
v3 scoped child handle depending on how the server emits namespaces
|
||||
for nested invokes — included so we can compare behavior.
|
||||
|
||||
2. `deep_agent`: built with `create_deep_agent` + one `SubAgent`. The
|
||||
supervisor's model is scripted to issue a `task(researcher, ...)`
|
||||
call, which IS the path that produces a proper scoped child handle
|
||||
on `thread.subgraphs`. This is the canonical exercise for the v3
|
||||
scoped-subgraph surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import check_api_reachable, header, make_async_client, make_sync_client
|
||||
|
||||
|
||||
async def _drain_subgraphs(thread) -> list:
|
||||
"""Drain ``thread.subgraphs`` to a list of {path, messages} dicts.
|
||||
|
||||
The outer subgraphs iterator must complete before we deep-iterate
|
||||
each handle's ``messages`` projection -- the same nested-iteration
|
||||
deadlock pattern as ``thread.tool_calls`` (see
|
||||
``test_tools.py``). So we first collect handles, then drain
|
||||
each handle's messages serially.
|
||||
"""
|
||||
handles: list = [h async for h in thread.subgraphs]
|
||||
children: list = []
|
||||
for child in handles:
|
||||
print(f" child handle path={child.path}")
|
||||
# Just count handle paths; deep message iteration on scoped
|
||||
# handles has its own draining pattern and isn't the goal of
|
||||
# this test (which exercises subgraph discovery via
|
||||
# child-namespace ``lifecycle: started``).
|
||||
children.append({"path": child.path})
|
||||
return children
|
||||
|
||||
|
||||
def _drain_subgraphs_sync(thread) -> list:
|
||||
handles = list(thread.subgraphs)
|
||||
children: list = []
|
||||
for child in handles:
|
||||
print(f" child handle path={child.path}")
|
||||
children.append({"path": child.path})
|
||||
return children
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
header("async subgraphs / agent (plain StateGraph)")
|
||||
async with threads.stream(assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
children = await _drain_subgraphs(thread)
|
||||
print(f" agent: total subgraph handles: {len(children)}")
|
||||
|
||||
header("async subgraphs / deep_agent (create_deep_agent + SubAgent)")
|
||||
async with threads.stream(assistant_id="deep_agent") as thread:
|
||||
await thread.run.start(
|
||||
input={
|
||||
"messages": [{"role": "user", "content": "research the v3 spec"}]
|
||||
},
|
||||
)
|
||||
children = await _drain_subgraphs(thread)
|
||||
print(f" deep_agent: total subgraph handles: {len(children)}")
|
||||
assert children, "deep_agent should produce at least one direct-child handle"
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
header("sync subgraphs / agent")
|
||||
with threads.stream(assistant_id="agent") as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
children = _drain_subgraphs_sync(thread)
|
||||
print(f" agent: total subgraph handles: {len(children)}")
|
||||
|
||||
header("sync subgraphs / deep_agent")
|
||||
with threads.stream(assistant_id="deep_agent") as thread:
|
||||
thread.run.start(
|
||||
input={
|
||||
"messages": [{"role": "user", "content": "research the v3 spec"}]
|
||||
},
|
||||
)
|
||||
children = _drain_subgraphs_sync(thread)
|
||||
print(f" deep_agent: total subgraph handles: {len(children)}")
|
||||
assert children, "deep_agent should produce at least one direct-child handle"
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Exercise `thread.tool_calls` against the `tools_agent` graph.
|
||||
|
||||
`tools_agent` (`graph/tools_agent.py`) wraps a
|
||||
`FakeMessagesListChatModel` in `create_agent` with a real `search`
|
||||
tool. The first scripted model turn returns an `AIMessage` with a
|
||||
`tool_calls=[search(query="v3")]`; langchain's tool node then executes
|
||||
`search` and surfaces a `ToolMessage`; the second turn returns a final
|
||||
`AIMessage("done.")` that terminates the agent.
|
||||
|
||||
Compared to `test_tool_calls.py` (which targets the synthetic
|
||||
`streaming_graph` and never produces real tool-call telemetry), this
|
||||
test verifies the v3 ``tools`` channel actually fires when the
|
||||
canonical langchain-agent surface is in play.
|
||||
|
||||
Pattern note: `thread.tool_calls` yields handles incrementally, but
|
||||
each handle's `deltas` and `output` are only completed when the
|
||||
*outer* iterator processes the matching `tool-finished` event. Drain
|
||||
the outer iterator to completion FIRST (via a list comprehension),
|
||||
then inspect handles -- this is the same pattern used in
|
||||
`tests/streaming/test_tool_calls_projection.py`. Iterating
|
||||
`handle.deltas` while the outer iterator is still suspended at its
|
||||
`yield` deadlocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import check_api_reachable, header, make_async_client, make_sync_client
|
||||
|
||||
TOOLS_ASSISTANT_ID = "tools_agent"
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async tools_agent tool_calls")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread:
|
||||
await thread.run.start(
|
||||
input={"messages": [{"role": "human", "content": "search for v3"}]}
|
||||
)
|
||||
|
||||
# Drain the outer iterator first; lifecycle-terminal triggers
|
||||
# the None sentinel via the shared SSE fanout once the run
|
||||
# completes naturally.
|
||||
handles = [h async for h in thread.tool_calls]
|
||||
print(f" total handles: {len(handles)}")
|
||||
for handle in handles:
|
||||
deltas = [d async for d in handle.deltas]
|
||||
output = await handle.output
|
||||
joined = "".join(deltas)
|
||||
print(
|
||||
f" tool {handle.name}({handle.tool_call_id}): "
|
||||
f"args_stream={joined!r} output={output!r}"
|
||||
)
|
||||
assert any(h.name == "search" for h in handles), (
|
||||
"expected `search` tool call"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync tools_agent tool_calls")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=TOOLS_ASSISTANT_ID) as thread:
|
||||
thread.run.start(
|
||||
input={"messages": [{"role": "human", "content": "search for v3"}]}
|
||||
)
|
||||
|
||||
handles = list(thread.tool_calls)
|
||||
print(f" total handles: {len(handles)}")
|
||||
for handle in handles:
|
||||
deltas = list(handle.deltas)
|
||||
output = handle.output
|
||||
joined = "".join(deltas)
|
||||
print(
|
||||
f" tool {handle.name}({handle.tool_call_id}): "
|
||||
f"args_stream={joined!r} output={output!r}"
|
||||
)
|
||||
assert any(h.name == "search" for h in handles), (
|
||||
"expected `search` tool call"
|
||||
)
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Exercise `threads.update_state(...)` mid-run against the integration API.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Stream the canonical graph; `run.start` with `value="init"`.
|
||||
2. Drain values until the interrupt fires at `ask_human`.
|
||||
3. Call `threads.update_state(thread_id, {"value": "patched"})` to mutate
|
||||
the persisted state while the run is paused.
|
||||
4. Read state back via `threads.get_state(thread_id)` and assert
|
||||
`state["values"]["value"] == "patched"`.
|
||||
|
||||
Why no respond afterwards: in langgraph-api, `update_state` against an
|
||||
interrupted thread commits a new checkpoint that consumes the
|
||||
outstanding interrupt. A subsequent `run.respond(...)` then fails with
|
||||
`no_such_interrupt`. The meaningful integration invariant here is just
|
||||
that the REST mutation lands on the same persisted thread the streaming
|
||||
proxy was driving (no thread_id drift between client and server).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
from langgraph_sdk.errors import ConflictError
|
||||
|
||||
_PATCHED_VALUE = "patched"
|
||||
_UPDATE_STATE_RETRY_BUDGET = 5.0
|
||||
|
||||
|
||||
async def _update_state_with_retry_async(threads, thread_id: str, values: dict) -> None:
|
||||
"""Retry update_state on ConflictError until the server's run row settles.
|
||||
|
||||
`thread.interrupted` flips when the client sees the `input.requested`
|
||||
lifecycle event, which can land before the server commits the run row
|
||||
to a non-busy state. Retry with backoff for a few seconds.
|
||||
"""
|
||||
delay = 0.05
|
||||
deadline = asyncio.get_running_loop().time() + _UPDATE_STATE_RETRY_BUDGET
|
||||
last_err: Exception | None = None
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
try:
|
||||
await threads.update_state(thread_id, values)
|
||||
return
|
||||
except ConflictError as err:
|
||||
last_err = err
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 0.5)
|
||||
raise AssertionError(
|
||||
f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}"
|
||||
)
|
||||
|
||||
|
||||
def _update_state_with_retry_sync(threads, thread_id: str, values: dict) -> None:
|
||||
delay = 0.05
|
||||
deadline = time.monotonic() + _UPDATE_STATE_RETRY_BUDGET
|
||||
last_err: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
threads.update_state(thread_id, values)
|
||||
return
|
||||
except ConflictError as err:
|
||||
last_err = err
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, 0.5)
|
||||
raise AssertionError(
|
||||
f"update_state never accepted within {_UPDATE_STATE_RETRY_BUDGET}s: {last_err!r}"
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async update_state during interrupt")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
async for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
assert thread.interrupted, "expected interrupt before update_state"
|
||||
|
||||
pre_state = await threads.get_state(thread.thread_id)
|
||||
pre_value = (pre_state.get("values") or {}).get("value")
|
||||
print(f" pre-update value={pre_value!r}")
|
||||
# `stream_message` overwrites value="init" with value="x" before the
|
||||
# interrupt; verify we're starting from the expected pre-update state.
|
||||
assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}"
|
||||
|
||||
await _update_state_with_retry_async(
|
||||
threads, thread.thread_id, {"value": _PATCHED_VALUE}
|
||||
)
|
||||
|
||||
post_state = await threads.get_state(thread.thread_id)
|
||||
post_values = post_state.get("values") or {}
|
||||
post_value = post_values.get("value")
|
||||
print(f" thread_id={thread.thread_id}")
|
||||
print(f" post-update value={post_value!r}")
|
||||
assert post_value == _PATCHED_VALUE, (
|
||||
f"update_state did not persist: value={post_value!r}"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync update_state during interrupt")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
for _ in thread.values:
|
||||
if thread.interrupted:
|
||||
break
|
||||
assert thread.interrupted, "expected interrupt before update_state"
|
||||
|
||||
pre_state = threads.get_state(thread.thread_id)
|
||||
pre_value = (pre_state.get("values") or {}).get("value")
|
||||
print(f" pre-update value={pre_value!r}")
|
||||
# `stream_message` overwrites value="init" with value="x" before the
|
||||
# interrupt; verify we're starting from the expected pre-update state.
|
||||
assert pre_value == "x", f"unexpected pre-update value: {pre_value!r}"
|
||||
|
||||
_update_state_with_retry_sync(
|
||||
threads, thread.thread_id, {"value": _PATCHED_VALUE}
|
||||
)
|
||||
|
||||
post_state = threads.get_state(thread.thread_id)
|
||||
post_values = post_state.get("values") or {}
|
||||
post_value = post_values.get("value")
|
||||
print(f" thread_id={thread.thread_id}")
|
||||
print(f" post-update value={post_value!r}")
|
||||
assert post_value == _PATCHED_VALUE, (
|
||||
f"update_state did not persist: value={post_value!r}"
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Exercise `thread.values` against the integration API.
|
||||
|
||||
The integration graph's `ask_human` node interrupts mid-run. The
|
||||
projection iterators (`thread.values`, `.messages`, `.tool_calls`,
|
||||
`.subgraphs`) do not terminate on interrupt — they're paused, waiting
|
||||
for more events. To drain the full run end-to-end we use a background
|
||||
auto-responder that watches `thread.interrupted` and calls
|
||||
`thread.run.respond(...)` so the run continues to the terminal.
|
||||
|
||||
Run after `docker compose up -d` from `libs/sdk-py/integration/`:
|
||||
|
||||
uv run python integration/scripts/test_values.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async values")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# Background task: respond to the interrupt so the iterator
|
||||
# eventually sees terminal-completion events.
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(
|
||||
f" values snapshot: items={snap.get('items')!r} value={snap.get('value')!r}"
|
||||
)
|
||||
|
||||
await responder
|
||||
|
||||
final = await thread.output
|
||||
print(f" final output items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), "expected subgraph to have run"
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync values")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(assistant_id=ASSISTANT_ID) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(
|
||||
f" values snapshot: items={snap.get('items')!r} value={snap.get('value')!r}"
|
||||
)
|
||||
|
||||
responder.join(timeout=5)
|
||||
|
||||
final = thread.output
|
||||
print(f" final output items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), "expected subgraph to have run"
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Exercise the WebSocket transport against the integration API.
|
||||
|
||||
Equivalent to `test_values.py` but with `transport="websocket"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from _common import (
|
||||
ASSISTANT_ID,
|
||||
auto_respond_async,
|
||||
auto_respond_sync,
|
||||
check_api_reachable,
|
||||
header,
|
||||
make_async_client,
|
||||
make_sync_client,
|
||||
)
|
||||
|
||||
|
||||
async def run_async() -> None:
|
||||
header("async websocket transport")
|
||||
threads, raw = make_async_client()
|
||||
try:
|
||||
async with threads.stream(
|
||||
assistant_id=ASSISTANT_ID,
|
||||
transport="websocket",
|
||||
) as thread:
|
||||
from langgraph_sdk.stream.transport import ProtocolWebSocketTransport
|
||||
|
||||
assert isinstance(thread._transport, ProtocolWebSocketTransport), (
|
||||
f"expected ws transport, got {type(thread._transport).__name__}"
|
||||
)
|
||||
|
||||
await thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
# The graph interrupts at `ask_human`; without a background
|
||||
# responder the values iterator would pause indefinitely.
|
||||
responder = auto_respond_async(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
async for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(f" ws values snapshot items={snap.get('items')!r}")
|
||||
|
||||
await responder
|
||||
|
||||
final = await thread.output
|
||||
print(f" final via ws: items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), (
|
||||
"expected subgraph to have run via ws transport"
|
||||
)
|
||||
finally:
|
||||
await raw.aclose()
|
||||
|
||||
|
||||
def run_sync() -> None:
|
||||
header("sync websocket transport")
|
||||
threads, raw = make_sync_client()
|
||||
try:
|
||||
with threads.stream(
|
||||
assistant_id=ASSISTANT_ID,
|
||||
transport="websocket",
|
||||
) as thread:
|
||||
thread.run.start(input={"messages": [], "value": "init", "items": []})
|
||||
|
||||
responder = auto_respond_sync(thread)
|
||||
|
||||
snapshots: list[dict] = []
|
||||
for snap in thread.values:
|
||||
snapshots.append(snap)
|
||||
print(f" ws values snapshot items={snap.get('items')!r}")
|
||||
|
||||
responder.join(timeout=5)
|
||||
|
||||
final = thread.output
|
||||
print(f" final via ws: items={final.get('items')!r}")
|
||||
assert "sub" in final.get("items", []), (
|
||||
"expected subgraph to have run via ws transport"
|
||||
)
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_api_reachable()
|
||||
asyncio.run(run_async())
|
||||
run_sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user