feat(sdk-py): add messages and tool call projections (#7823)

This commit is contained in:
Nick Hollon
2026-05-27 12:11:06 -04:00
committed by GitHub
parent 66ec594540
commit 30fea64687
10 changed files with 1042 additions and 10 deletions
+2
View File
@@ -1828,6 +1828,7 @@ name = "langgraph-sdk"
source = { editable = "../sdk-py" }
dependencies = [
{ name = "httpx" },
{ name = "langchain-core" },
{ name = "langchain-protocol" },
{ name = "orjson" },
]
@@ -1835,6 +1836,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-protocol", specifier = ">=0.0.15" },
{ name = "orjson", specifier = ">=3.11.5" },
]
+2
View File
@@ -597,6 +597,7 @@ name = "langgraph-sdk"
source = { editable = "../sdk-py" }
dependencies = [
{ name = "httpx" },
{ name = "langchain-core" },
{ name = "langchain-protocol" },
{ name = "orjson" },
]
@@ -604,6 +605,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-protocol", specifier = ">=0.0.15" },
{ name = "orjson", specifier = ">=3.11.5" },
]
+293 -9
View File
@@ -18,6 +18,7 @@ from collections.abc import AsyncGenerator, AsyncIterator, Generator, Mapping
from dataclasses import dataclass, field
from typing import Any, Literal, TypedDict
from langchain_core.language_models.chat_model_stream import AsyncChatModelStream
from langchain_protocol import Event, SubscribeParams
from langgraph_sdk._async.http import HttpClient
@@ -285,6 +286,265 @@ class _ValuesProjection:
self._thread._unregister_subscription(sub.id)
class _MessagesProjection:
"""Typed projection for root-scope `thread.messages`.
Iterating yields one `AsyncChatModelStream` per message-start event.
Each iterator owns its own `messages` subscription and routes events
from the root namespace only.
"""
def __init__(self, thread: AsyncThreadStream) -> None:
self._thread = thread
def __aiter__(self) -> AsyncIterator[AsyncChatModelStream]:
return self._messages_iter()
async def _messages_iter(self) -> AsyncGenerator[AsyncChatModelStream, None]:
if self._thread._transport is None:
raise RuntimeError("AsyncThreadStream not entered — use `async with`.")
params: SubscribeParams = {
"channels": ["messages"],
"namespaces": [[]],
"depth": 0,
}
sub = self._thread._register_subscription(params)
active: dict[str, AsyncChatModelStream] = {}
try:
await self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
while True:
item = await sub.queue.get()
if item is None:
return
params_field = item.get("params") or {}
if not isinstance(params_field, dict):
continue
if params_field.get("namespace") not in (None, []):
continue
data = params_field.get("data")
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=[],
node=metadata.get("langgraph_node") if metadata else None,
message_id=message_id,
)
active[key] = stream
self._thread._register_active_message_stream(stream)
stream.dispatch(data)
yield stream
else:
key = _message_route_key(data)
stream = active.get(key)
if stream is None:
# No active stream matches this event's key. Drop rather
# than silently misroute to the only remaining stream.
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():
self._thread._unregister_active_message_stream(stream)
self._thread._unregister_subscription(sub.id)
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__"
class ToolCallHandle:
"""Async handle for one root-scope tool call."""
def __init__(
self,
*,
tool_call_id: str,
name: str,
input: Any = None,
namespace: list[str] | None = None,
max_queue_size: int = 1024,
) -> None:
self.tool_call_id = tool_call_id
self.name = name
self.input = input
self.namespace = list(namespace or [])
self.done = False
self.error: BaseException | None = None
loop = asyncio.get_running_loop()
self.output: asyncio.Future[Any] = loop.create_future()
self._deltas: asyncio.Queue[str | None] = asyncio.Queue(maxsize=max_queue_size)
self._deltas_consumed = False
@property
def deltas(self) -> AsyncIterator[str]:
"""Stream tool output deltas emitted before the terminal event.
Raises:
RuntimeError: if called more than once — the underlying queue is
single-consumer and cannot be fanned out safely.
"""
if self._deltas_consumed:
raise RuntimeError(
"ToolCallHandle.deltas can only be iterated by a single consumer."
)
self._deltas_consumed = True
return self._delta_iter()
async def _delta_iter(self) -> AsyncGenerator[str, None]:
while True:
item = await self._deltas.get()
if item is None:
return # errors surface via output, not deltas
yield item
def _push_delta(self, delta: str) -> None:
if self.done:
return
self._deltas.put_nowait(delta)
def _finish(self, output: Any) -> None:
if self.done:
return
self.done = True
if not self.output.done():
self.output.set_result(output)
self._deltas.put_nowait(None)
def _fail(self, err: BaseException) -> None:
if self.done:
return
self.done = True
self.error = err
if not self.output.done():
self.output.set_exception(err)
self._deltas.put_nowait(None)
class _ToolCallsProjection:
"""Typed projection for root-scope `thread.tool_calls`."""
def __init__(self, thread: AsyncThreadStream) -> None:
self._thread = thread
def __aiter__(self) -> AsyncIterator[ToolCallHandle]:
return self._tool_calls_iter()
async def _tool_calls_iter(self) -> AsyncGenerator[ToolCallHandle, None]:
if self._thread._transport is None:
raise RuntimeError("AsyncThreadStream not entered - use `async with`.")
params: SubscribeParams = {
"channels": ["tools"],
"namespaces": [[]],
"depth": 0,
}
sub = self._thread._register_subscription(params)
active: dict[str, ToolCallHandle] = {}
try:
await self._thread._reconcile_stream(params)
self._thread._ensure_fanout_running()
while True:
item = await sub.queue.get()
if item is None:
return
params_field = item.get("params") or {}
if not isinstance(params_field, dict):
continue
namespace = params_field.get("namespace") or []
if namespace != []:
continue
data = params_field.get("data")
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=namespace,
)
active[tool_call_id] = handle
self._thread._register_active_tool_call(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
# should await `thread.output` directly. Blocking in iterator
# teardown would stall every early break or exception exit for
# up to the full shield-wait timeout (previously 1 s).
run_done = self._thread._run_done
terminal_err: BaseException | None = None
if run_done is not None and run_done.done() and not run_done.cancelled():
terminal = run_done.result()
terminal_err = terminal.error
err = (
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)
handle._fail(err)
self._thread._unregister_subscription(sub.id)
class AsyncThreadStream:
"""Async context manager for one thread's v3 streaming session.
@@ -332,9 +592,13 @@ class AsyncThreadStream:
self._run_start_ready: asyncio.Future[None] | None = None
self._run_seen: bool = False
self._run_done: asyncio.Future[_RunTerminal] | None = None
self._active_message_streams: set[AsyncChatModelStream] = set()
self._active_tool_calls: set[ToolCallHandle] = set()
self.run = RunModule(self)
self.output = _OutputAwaitable(self)
self.values = _ValuesProjection(self)
self.messages = _MessagesProjection(self)
self.tool_calls = _ToolCallsProjection(self)
@property
def _controller(self) -> AsyncThreadStream:
@@ -402,6 +666,8 @@ class AsyncThreadStream:
await self._lifecycle_watcher_task
if self._lifecycle_watcher_handle is not None:
await self._lifecycle_watcher_handle.close()
self._fail_active_message_streams(asyncio.CancelledError())
self._fail_active_tool_calls(asyncio.CancelledError())
if self._fanout_task is not None:
self._fanout_task.cancel()
with contextlib.suppress(Exception, asyncio.CancelledError):
@@ -426,6 +692,28 @@ class AsyncThreadStream:
"""Remove a subscription from the registry. No-op if already absent."""
self._subscriptions.pop(subscription_id, None)
def _register_active_message_stream(self, stream: AsyncChatModelStream) -> None:
self._active_message_streams.add(stream)
def _unregister_active_message_stream(self, stream: AsyncChatModelStream) -> None:
self._active_message_streams.discard(stream)
def _fail_active_message_streams(self, err: BaseException) -> None:
for stream in list(self._active_message_streams):
stream.fail(err)
self._active_message_streams.clear()
def _register_active_tool_call(self, handle: ToolCallHandle) -> None:
self._active_tool_calls.add(handle)
def _unregister_active_tool_call(self, handle: ToolCallHandle) -> None:
self._active_tool_calls.discard(handle)
def _fail_active_tool_calls(self, err: BaseException) -> None:
for handle in list(self._active_tool_calls):
handle._fail(err)
self._active_tool_calls.clear()
def subscribe(
self,
channels: list[str],
@@ -750,15 +1038,11 @@ class AsyncThreadStream:
error_msg = (
data.get("error") if isinstance(data, dict) else None
)
run_done.set_result(
_RunTerminal(
status="errored",
error=RuntimeError(
f"Run errored: {error_msg}"
if error_msg
else "Run errored"
),
)
error = RuntimeError(
f"Run errored: {error_msg}" if error_msg else "Run errored"
)
self._fail_active_message_streams(error)
self._fail_active_tool_calls(error)
run_done.set_result(_RunTerminal(status="errored", error=error))
else:
run_done.set_result(_RunTerminal(status="completed"))
@@ -0,0 +1,15 @@
"""Stream module for LangGraph SDK v3."""
from langchain_protocol import (
Channel,
Event,
Namespace,
SubscribeParams,
)
__all__ = [
"Channel",
"Event",
"Namespace",
"SubscribeParams",
]
+6 -1
View File
@@ -11,7 +11,12 @@ requires-python = ">=3.10"
readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = ["httpx>=0.25.2", "orjson>=3.11.5", "langchain-protocol>=0.0.15"]
dependencies = [
"httpx>=0.25.2",
"orjson>=3.11.5",
"langchain-protocol>=0.0.15",
"langchain-core>=1.4.0,<2",
]
[tool.hatch.version]
path = "langgraph_sdk/__init__.py"
+215
View File
@@ -69,3 +69,218 @@ def input_requested_event(
seq: int = 0, namespace: list[str] | None = None
) -> dict[str, Any]:
return _base(seq, "input.requested", namespace or [], {"interrupt_id": "i-1"})
def message_start_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
message_id: str = "msg-1",
role: str = "ai",
run_id: str = "run-1",
node: str = "agent",
) -> dict[str, Any]:
return _base(
seq,
"messages",
namespace or [],
{
"event": "message-start",
"id": message_id,
"role": role,
"metadata": {"run_id": run_id, "langgraph_node": node},
},
)
def message_text_delta_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
text: str,
index: int = 0,
message_id: str | None = None,
) -> dict[str, Any]:
data: dict[str, Any] = {
"event": "content-block-delta",
"index": index,
"delta": {"type": "text-delta", "text": text},
}
if message_id is not None:
data["id"] = message_id
return _base(seq, "messages", namespace or [], data)
def message_text_finish_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
text: str,
index: int = 0,
message_id: str | None = None,
) -> dict[str, Any]:
data: dict[str, Any] = {
"event": "content-block-finish",
"index": index,
"content": {"type": "text", "text": text},
}
if message_id is not None:
data["id"] = message_id
return _base(seq, "messages", namespace or [], data)
def message_finish_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
input_tokens: int = 1,
output_tokens: int = 1,
message_id: str | None = None,
) -> dict[str, Any]:
data: dict[str, Any] = {
"event": "message-finish",
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
},
}
if message_id is not None:
data["id"] = message_id
return _base(seq, "messages", namespace or [], data)
def message_error_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
message: str = "model failed",
code: str = "provider_error",
message_id: str | None = None,
) -> dict[str, Any]:
data: dict[str, Any] = {"event": "error", "message": message, "code": code}
if message_id is not None:
data["id"] = message_id
return _base(seq, "messages", namespace or [], data)
def tool_started_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
tool_call_id: str = "call-1",
tool_name: str = "search",
input: Any = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"event": "tool-started",
"tool_call_id": tool_call_id,
"tool_name": tool_name,
}
if input is not None:
payload["input"] = input
return _base(seq, "tools", namespace or [], payload)
def tool_output_delta_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
tool_call_id: str = "call-1",
delta: str = "",
) -> dict[str, Any]:
return _base(
seq,
"tools",
namespace or [],
{
"event": "tool-output-delta",
"tool_call_id": tool_call_id,
"delta": delta,
},
)
def tool_finished_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
tool_call_id: str = "call-1",
output: Any = None,
) -> dict[str, Any]:
return _base(
seq,
"tools",
namespace or [],
{
"event": "tool-finished",
"tool_call_id": tool_call_id,
"output": output,
},
)
def tool_error_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
tool_call_id: str = "call-1",
message: str = "tool failed",
code: str = "tool_error",
) -> dict[str, Any]:
return _base(
seq,
"tools",
namespace or [],
{
"event": "tool-error",
"tool_call_id": tool_call_id,
"message": message,
"code": code,
},
)
def tasks_start_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
task_id: str = "task-1",
name: str = "node",
input: Any = None,
) -> dict[str, Any]:
return _base(
seq,
"tasks",
namespace or [],
{
"id": task_id,
"name": name,
"input": input,
"triggers": [],
},
)
def tasks_result_event(
seq: int = 0,
namespace: list[str] | None = None,
*,
task_id: str = "task-1",
name: str = "node",
result: Any = None,
error: str | None = None,
interrupts: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return _base(
seq,
"tasks",
namespace or [],
{
"id": task_id,
"name": name,
"result": result if result is not None else {},
"error": error,
"interrupts": interrupts or [],
},
)
@@ -0,0 +1,209 @@
"""Tests for `thread.messages` - typed async message projection."""
from __future__ import annotations
import httpx
import pytest
from langchain_core.language_models.chat_model_stream import AsyncChatModelStream
from langchain_core.messages import AIMessage
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
message_error_event,
message_finish_event,
message_start_event,
message_text_delta_event,
message_text_finish_event,
)
from streaming._fake_server import FakeServer
async def test_messages_subscribes_to_messages_channel():
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=1)])
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={})
_ = [message async for message in thread.messages]
assert any(
"messages" in body.get("channels", []) for body in fake.stream_request_bodies
)
async def test_messages_yields_async_chat_model_stream_and_text_deltas():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
message_start_event(seq=1, message_id="msg-1", run_id="run-1"),
message_text_delta_event(seq=2, text="hel", message_id="msg-1"),
message_text_delta_event(seq=3, text="lo", message_id="msg-1"),
message_text_finish_event(seq=4, text="hello", message_id="msg-1"),
message_finish_event(
seq=5, input_tokens=2, output_tokens=3, message_id="msg-1"
),
lifecycle_completed_event(seq=6),
]
)
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={})
streams = [message async for message in thread.messages]
assert len(streams) == 1
message = streams[0]
assert isinstance(message, AsyncChatModelStream)
assert message.message_id == "msg-1"
assert [delta async for delta in message.text] == ["hel", "lo"]
assert await message.text == "hello"
output = await message.output
assert isinstance(output, AIMessage)
assert output.id == "msg-1"
assert output.content == [{"type": "text", "text": "hello", "index": 0}]
assert output.usage_metadata == {
"input_tokens": 2,
"output_tokens": 3,
"total_tokens": 5,
}
async def test_messages_multiple_messages_are_distinct_streams():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
message_start_event(seq=1, message_id="msg-1", run_id="run-1"),
message_text_delta_event(seq=2, text="one", message_id="msg-1"),
message_text_finish_event(seq=3, text="one", message_id="msg-1"),
message_finish_event(seq=4, message_id="msg-1"),
message_start_event(seq=5, message_id="msg-2", run_id="run-2"),
message_text_delta_event(seq=6, text="two", message_id="msg-2"),
message_text_finish_event(seq=7, text="two", message_id="msg-2"),
message_finish_event(seq=8, message_id="msg-2"),
lifecycle_completed_event(seq=9),
]
)
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={})
streams = [message async for message in thread.messages]
assert [stream.message_id for stream in streams] == ["msg-1", "msg-2"]
assert [await stream.text for stream in streams] == ["one", "two"]
async def test_messages_ignores_nested_namespace_for_root_projection():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
message_start_event(seq=1, namespace=["child:1"], message_id="nested"),
message_text_delta_event(seq=2, namespace=["child:1"], text="nested"),
message_finish_event(seq=3, namespace=["child:1"]),
lifecycle_completed_event(seq=4),
]
)
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={})
streams = [message async for message in thread.messages]
assert streams == []
async def test_messages_error_event_fails_active_stream():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
message_start_event(seq=1, message_id="msg-1"),
message_error_event(seq=2, message="model failed", message_id="msg-1"),
lifecycle_completed_event(seq=3),
]
)
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={})
streams = [message async for message in thread.messages]
assert len(streams) == 1
with pytest.raises(RuntimeError, match="model failed"):
await streams[0].output
async def test_messages_concurrent_same_run_id_route_independently():
"""Two messages sharing a run_id must route to independent streams.
The old `_message_route_key` keyed on `run_id` when present, so both
message-start events mapped to the same `active` slot and the second
overwrote the first. Subsequent deltas and finish events all routed to
the wrong (or missing) stream.
"""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
# Both messages share run_id="run-1" (same agent turn)
message_start_event(seq=1, message_id="msg-A", run_id="run-1"),
message_start_event(seq=2, message_id="msg-B", run_id="run-1"),
message_text_delta_event(seq=3, text="alpha", message_id="msg-A"),
message_text_delta_event(seq=4, text="beta", message_id="msg-B"),
message_finish_event(seq=5, message_id="msg-A"),
message_finish_event(seq=6, message_id="msg-B"),
lifecycle_completed_event(seq=7),
]
)
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={})
streams = [msg async for msg in thread.messages]
assert [s.message_id for s in streams] == ["msg-A", "msg-B"]
assert [await s.text for s in streams] == ["alpha", "beta"]
async def test_messages_orphan_delta_without_matching_key_is_dropped():
"""A delta whose message_id doesn't match any active stream must be dropped.
The old code fell back to routing the event to the only active stream when
`len(active) == 1`, causing orphan/mismatched deltas to silently corrupt
an unrelated stream's content.
"""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
message_start_event(seq=1, message_id="msg-A"),
# Delta with a mismatched message_id — must be dropped, not routed to msg-A.
message_text_delta_event(seq=2, text="orphan", message_id="msg-UNKNOWN"),
message_text_delta_event(seq=3, text="real", message_id="msg-A"),
message_finish_event(seq=4, message_id="msg-A"),
lifecycle_completed_event(seq=5),
]
)
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={})
streams = [msg async for msg in thread.messages]
assert len(streams) == 1
# Only the correctly-keyed delta "real" must appear; "orphan" must be dropped.
assert await streams[0].text == "real"
@@ -1,7 +1,25 @@
from __future__ import annotations
import pytest
from langchain_protocol import (
Channel as ProtocolChannel,
)
from langchain_protocol import (
Event as ProtocolEvent,
)
from langchain_protocol import (
Namespace as ProtocolNamespace,
)
from langchain_protocol import (
SubscribeParams as ProtocolSubscribeParams,
)
from langgraph_sdk.stream import (
Channel,
Event,
Namespace,
SubscribeParams,
)
from langgraph_sdk.stream.subscription import (
compute_union_filter,
filter_covers,
@@ -236,3 +254,11 @@ def test_filter_covers_bounded_coverer_does_not_cover_unbounded_target():
coverer = {"channels": ["values"], "depth": 2}
target = {"channels": ["values"]} # unbounded
assert filter_covers(coverer, target) is False
def test_protocol_types_are_importable_from_stream_module():
"""Test that v3 protocol types are re-exported from langgraph_sdk.stream."""
assert Channel is ProtocolChannel
assert Event is ProtocolEvent
assert Namespace is ProtocolNamespace
assert SubscribeParams is ProtocolSubscribeParams
@@ -0,0 +1,272 @@
"""Tests for `thread.tool_calls` - typed async tool-call projection."""
from __future__ import annotations
import time
import httpx
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import (
lifecycle_completed_event,
lifecycle_errored_event,
lifecycle_started_event,
tool_error_event,
tool_finished_event,
tool_output_delta_event,
tool_started_event,
)
from streaming._fake_server import FakeServer
async def test_tool_calls_subscribes_to_tools_channel():
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=1)])
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={})
_ = [call async for call in thread.tool_calls]
assert any(
"tools" in body.get("channels", []) for body in fake.stream_request_bodies
)
async def test_tool_calls_yields_handle_deltas_and_output():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(
seq=1,
tool_call_id="call-1",
tool_name="search",
input={"query": "sf weather"},
),
tool_output_delta_event(seq=2, tool_call_id="call-1", delta="part "),
tool_output_delta_event(seq=3, tool_call_id="call-1", delta="two"),
tool_finished_event(
seq=4,
tool_call_id="call-1",
output={"temperature": 68},
),
lifecycle_completed_event(seq=5),
]
)
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={})
calls = [call async for call in thread.tool_calls]
assert len(calls) == 1
call = calls[0]
assert call.tool_call_id == "call-1"
assert call.name == "search"
assert call.input == {"query": "sf weather"}
assert call.namespace == []
assert call.done is True
assert [delta async for delta in call.deltas] == ["part ", "two"]
assert await call.output == {"temperature": 68}
async def test_tool_calls_multiple_concurrent_calls_route_by_id():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-a", tool_name="alpha"),
tool_started_event(seq=2, tool_call_id="call-b", tool_name="beta"),
tool_output_delta_event(seq=3, tool_call_id="call-b", delta="b1"),
tool_output_delta_event(seq=4, tool_call_id="call-a", delta="a1"),
tool_finished_event(seq=5, tool_call_id="call-a", output="A"),
tool_finished_event(seq=6, tool_call_id="call-b", output="B"),
lifecycle_completed_event(seq=7),
]
)
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={})
calls = [call async for call in thread.tool_calls]
by_id = {call.tool_call_id: call for call in calls}
assert set(by_id) == {"call-a", "call-b"}
assert [delta async for delta in by_id["call-a"].deltas] == ["a1"]
assert [delta async for delta in by_id["call-b"].deltas] == ["b1"]
assert await by_id["call-a"].output == "A"
assert await by_id["call-b"].output == "B"
async def test_tool_calls_ignores_nested_namespace_for_root_projection():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, namespace=["child:1"], tool_call_id="nested"),
tool_finished_event(seq=2, namespace=["child:1"], tool_call_id="nested"),
lifecycle_completed_event(seq=3),
]
)
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={})
calls = [call async for call in thread.tool_calls]
assert calls == []
async def test_tool_calls_error_event_fails_output_and_deltas():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1"),
tool_output_delta_event(seq=2, tool_call_id="call-1", delta="before"),
tool_error_event(seq=3, tool_call_id="call-1", message="boom"),
lifecycle_completed_event(seq=4),
]
)
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={})
calls = [call async for call in thread.tool_calls]
assert len(calls) == 1
assert [delta async for delta in calls[0].deltas] == ["before"]
with pytest.raises(RuntimeError, match="boom"):
await calls[0].output
async def test_tool_calls_run_error_fails_active_handle():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1"),
lifecycle_errored_event(seq=2, error="run failed"),
]
)
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={})
calls = [call async for call in thread.tool_calls]
assert len(calls) == 1
with pytest.raises(RuntimeError, match="Run errored: run failed"):
await calls[0].output
async def test_tool_calls_stream_end_fails_active_handle():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1"),
lifecycle_completed_event(seq=2),
]
)
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={})
calls = [call async for call in thread.tool_calls]
assert len(calls) == 1
with pytest.raises(RuntimeError, match="closed before terminal tool event"):
await calls[0].output
async def test_tool_calls_explicit_aclose_does_not_block_1s():
"""Explicitly closing the tool_calls iterator must return in <500ms.
The old finally block did `await asyncio.wait_for(asyncio.shield(run_done),
timeout=1.0)` unconditionally. When the caller explicitly calls aclose() on
the generator before any lifecycle terminal event arrives, this caused a
mandatory 1-second stall per iterator close.
"""
fake = FakeServer()
# Script has a started lifecycle and one tool, but NO terminal lifecycle.
# If the shield-wait is present, aclose() will block for 1s.
fake.script(
[
lifecycle_started_event(seq=0),
tool_started_event(seq=1, tool_call_id="call-1"),
]
)
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={})
# _tool_calls_iter() is an AsyncGenerator; cast so the type checker
# knows aclose() is available without a bare AsyncIterator protocol.
from collections.abc import AsyncGenerator
gen: AsyncGenerator = thread.tool_calls._tool_calls_iter()
_call = await gen.__anext__() # receive the one tool-started handle
start = time.monotonic()
await gen.aclose() # explicitly close — must not stall 1s
elapsed = time.monotonic() - start
assert elapsed < 0.5, f"tool_calls aclose() took {elapsed:.3f}s (expected <0.5s)"
def test_tool_call_handle_deltas_queue_is_bounded():
"""ToolCallHandle._deltas must be constructed with a bounded asyncio.Queue.
Unbounded queues allow producers to enqueue indefinitely, causing memory
growth when consumers are slow.
"""
import asyncio
# We need a running loop to create the Future inside ToolCallHandle.__init__.
async def _make() -> None:
from langgraph_sdk._async.stream import ToolCallHandle
handle_default = ToolCallHandle(tool_call_id="tc1", name="foo")
assert handle_default._deltas.maxsize > 0, (
"default maxsize must be positive (bounded)"
)
handle_custom = ToolCallHandle(tool_call_id="tc2", name="bar", max_queue_size=8)
assert handle_custom._deltas.maxsize == 8
asyncio.run(_make())
def test_tool_call_handle_deltas_single_consumer_guard():
"""Accessing `handle.deltas` a second time must raise immediately.
`_deltas` is a single-consumer queue; fanning out to multiple consumers
would cause each consumer to miss events already consumed by the other.
The property must raise before returning the iterator so the caller
sees the error even without iterating.
"""
import asyncio
async def _run() -> None:
from langgraph_sdk._async.stream import ToolCallHandle
handle = ToolCallHandle(tool_call_id="tc1", name="foo")
# First access: fine — returns the iterator.
_iter_1 = handle.deltas
# Second access: must raise immediately (before any iteration).
with pytest.raises(RuntimeError, match="single consumer"):
_ = handle.deltas
asyncio.run(_run())
+2
View File
@@ -484,6 +484,7 @@ name = "langgraph-sdk"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "langchain-core" },
{ name = "langchain-protocol" },
{ name = "orjson" },
]
@@ -519,6 +520,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.25.2" },
{ name = "langchain-core", specifier = ">=1.4.0,<2" },
{ name = "langchain-protocol", specifier = ">=0.0.15" },
{ name = "orjson", specifier = ">=3.11.5" },
]