Files
2026-04-28 14:45:23 -07:00

229 lines
7.0 KiB
Python

"""Async thread-centric streaming primitives."""
from __future__ import annotations
import time
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any, cast
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk.protocol import (
Channel,
Command,
CommandMethod,
CommandResponse,
ErrorResponse,
Event,
SubscribeParams,
)
from langgraph_sdk.schema import QueryParamTypes, StreamPart
def _stream_part_to_event(part: StreamPart) -> Event:
"""Normalize an SSE ``StreamPart`` into a protocol event envelope."""
if isinstance(part.data, dict) and part.data.get("type") == "event":
event = cast(Event, part.data)
else:
event = {
"type": "event",
"method": part.event,
"params": {
"namespace": [],
"timestamp": int(time.time() * 1000),
"data": part.data,
},
}
if part.id is not None and "event_id" not in event:
event["event_id"] = part.id
return event
class EventSubscription:
"""Async iterable handle for a filtered event subscription."""
def __init__(
self,
subscription_id: str,
params: SubscribeParams,
events: AsyncIterator[Event],
on_unsubscribe: Any,
) -> None:
self.subscription_id = subscription_id
self.params = params
self._events = events
self._on_unsubscribe = on_unsubscribe
def __aiter__(self) -> AsyncIterator[Event]:
return self._events
async def unsubscribe(self) -> None:
await self._on_unsubscribe(self.subscription_id)
class ProtocolSseTransport:
"""SSE transport for the thread-centric protocol."""
def __init__(
self,
http: HttpClient,
thread_id: str,
*,
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
) -> None:
self.http = http
self.thread_id = thread_id
self.headers = headers
self.params = params
self.commands_path = f"/v2/threads/{thread_id}/commands"
self.events_path = f"/v2/threads/{thread_id}/events"
async def send(self, command: Command) -> CommandResponse | ErrorResponse | None:
return await self.http.post(
self.commands_path,
json=cast(dict[str, Any], command),
headers=self.headers,
params=self.params,
)
def open_event_stream(self, params: SubscribeParams) -> AsyncIterator[Event]:
async def iterate() -> AsyncIterator[Event]:
async for part in self.http.stream(
self.events_path,
"POST",
json=cast(dict[str, Any], params),
headers=self.headers,
params=self.params,
):
yield _stream_part_to_event(part)
return iterate()
class RunModule:
"""Run commands exposed by ``ThreadStream.run``."""
def __init__(self, stream: ThreadStream) -> None:
self._stream = stream
async def input(self, params: Mapping[str, Any]) -> Any:
return await self._stream.command("run.input", dict(params))
class InputModule:
"""Human-input commands exposed by ``ThreadStream.input``."""
def __init__(self, stream: ThreadStream) -> None:
self._stream = stream
async def respond(self, params: Mapping[str, Any]) -> Any:
return await self._stream.command("input.respond", dict(params))
async def inject(self, params: Mapping[str, Any]) -> Any:
return await self._stream.command("input.inject", dict(params))
class StateModule:
"""State commands exposed by ``ThreadStream.state``."""
def __init__(self, stream: ThreadStream) -> None:
self._stream = stream
async def get(self, params: Mapping[str, Any] | None = None) -> Any:
return await self._stream.command("state.get", dict(params or {}))
async def list_checkpoints(self, params: Mapping[str, Any] | None = None) -> Any:
return await self._stream.command("state.listCheckpoints", dict(params or {}))
async def fork(self, params: Mapping[str, Any]) -> Any:
return await self._stream.command("state.fork", dict(params))
class AgentModule:
"""Agent commands exposed by ``ThreadStream.agent``."""
def __init__(self, stream: ThreadStream) -> None:
self._stream = stream
async def get_tree(self, params: Mapping[str, Any] | None = None) -> Any:
return await self._stream.command("agent.getTree", dict(params or {}))
class ThreadStream:
"""High-level async wrapper around a thread protocol transport."""
def __init__(
self,
transport: ProtocolSseTransport,
*,
assistant_id: str,
starting_command_id: int = 0,
) -> None:
if not assistant_id:
raise ValueError("assistant_id is required")
self.transport = transport
self.assistant_id = assistant_id
self._next_command_id = starting_command_id
self._next_subscription_id = 0
self.run = RunModule(self)
self.input = InputModule(self)
self.state = StateModule(self)
self.agent = AgentModule(self)
@property
def thread_id(self) -> str:
return self.transport.thread_id
def _command_id(self) -> str:
self._next_command_id += 1
return str(self._next_command_id)
def _subscription_id(self) -> str:
self._next_subscription_id += 1
return f"sub-{self._next_subscription_id}"
async def command(self, method: CommandMethod, params: dict[str, Any]) -> Any:
command: Command = {
"id": self._command_id(),
"method": method,
"params": params,
}
if method == "run.input":
command["params"] = {"assistant_id": self.assistant_id, **params}
response = await self.transport.send(command)
if response is None:
return None
if "error" in response:
raise RuntimeError(response["error"])
return response.get("result")
async def subscribe(
self,
channels: Sequence[Channel | str] | SubscribeParams,
*,
namespaces: Sequence[Sequence[str]] | None = None,
depth: int | None = None,
) -> EventSubscription:
if isinstance(channels, dict):
params = SubscribeParams(**channels)
else:
params = SubscribeParams(channels=list(channels))
if namespaces is not None:
params["namespaces"] = [list(ns) for ns in namespaces]
if depth is not None:
params["depth"] = depth
subscription_id = self._subscription_id()
events = self.transport.open_event_stream(params)
return EventSubscription(subscription_id, params, events, self._unsubscribe)
async def _unsubscribe(self, subscription_id: str) -> None:
await self.command(
"subscription.unsubscribe",
{"subscription_id": subscription_id},
)
async def close(self) -> None:
return None