mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-07 02:07:52 +02:00
229 lines
7.0 KiB
Python
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
|
|
|