Files
2026-07-13 13:39:38 +08:00

263 lines
8.8 KiB
Python

# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Any, Generic
from langchain_core.messages import AIMessage, BaseMessageChunk, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from langgraph.pregel.protocol import PregelProtocol
from langgraph.types import StreamMode
from langgraph.typing import ContextT
from livekit.agents import llm, utils
from livekit.agents.llm import ToolChoice
from livekit.agents.llm.chat_context import ChatContext
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
_SUPPORTED_MODES: set[StreamMode] = {"messages", "custom"}
class LLMAdapter(llm.LLM, Generic[ContextT]):
def __init__(
self,
graph: PregelProtocol[Any, ContextT, Any, Any],
*,
config: RunnableConfig | None = None,
context: ContextT | None = None,
subgraphs: bool = False,
stream_mode: StreamMode | list[StreamMode] = "messages",
) -> None:
super().__init__()
modes = {stream_mode} if isinstance(stream_mode, str) else set(stream_mode)
unsupported = modes - _SUPPORTED_MODES
if unsupported:
raise ValueError(
f"Unsupported stream mode(s): {unsupported}. Only {_SUPPORTED_MODES} are supported."
)
self._graph = graph
self._config = config
self._context = context
self._subgraphs = subgraphs
self._stream_mode = stream_mode
@property
def model(self) -> str:
return "unknown"
@property
def provider(self) -> str:
return "LangChain"
def chat(
self,
*,
chat_ctx: ChatContext,
tools: list[llm.Tool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
# these are unused, since tool execution takes place in langgraph
parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> LangGraphStream[ContextT]:
return LangGraphStream(
self,
chat_ctx=chat_ctx,
tools=tools or [],
graph=self._graph,
conn_options=conn_options,
config=self._config,
context=self._context,
subgraphs=self._subgraphs,
stream_mode=self._stream_mode,
)
class LangGraphStream(llm.LLMStream, Generic[ContextT]):
def __init__(
self,
llm: LLMAdapter[ContextT],
*,
chat_ctx: ChatContext,
tools: list[llm.Tool],
conn_options: APIConnectOptions,
graph: PregelProtocol[Any, ContextT, Any, Any],
config: RunnableConfig | None = None,
context: ContextT | None = None,
subgraphs: bool = False,
stream_mode: StreamMode | list[StreamMode] = "messages",
):
super().__init__(
llm,
chat_ctx=chat_ctx,
tools=tools,
conn_options=conn_options,
)
self._graph = graph
self._config = config
self._context = context
self._subgraphs = subgraphs
self._stream_mode = stream_mode
async def _run(self) -> None:
state = self._chat_ctx_to_state()
is_multi_mode = isinstance(self._stream_mode, list)
# Some LangGraph versions don't accept the `subgraphs` or `context` kwargs yet.
# Try with them first; fall back gracefully if unsupported.
try:
aiter = self._graph.astream(
state,
self._config,
context=self._context,
stream_mode=self._stream_mode,
subgraphs=self._subgraphs,
)
except TypeError:
aiter = self._graph.astream(
state,
self._config,
stream_mode=self._stream_mode,
)
async for item in aiter:
# Multi-mode: item is (mode, data) tuple wrapper
if is_multi_mode and isinstance(item, tuple) and len(item) == 2:
mode, data = item
if isinstance(mode, str):
if mode == "custom":
# data = payload (str, dict, object)
chat_chunk = _to_chat_chunk(data)
if chat_chunk:
self._event_ch.send_nowait(chat_chunk)
continue
elif mode == "messages":
# data = (token, metadata)
token_like = _extract_message_chunk(data)
if token_like is None:
continue
chat_chunk = _to_chat_chunk(token_like)
if chat_chunk:
self._event_ch.send_nowait(chat_chunk)
continue
# Single-mode: item is data directly (no tuple wrapper)
if self._stream_mode == "custom":
# item = payload (str, dict, object)
chat_chunk = _to_chat_chunk(item)
if chat_chunk:
self._event_ch.send_nowait(chat_chunk)
elif self._stream_mode == "messages":
# item = (token, metadata)
token_like = _extract_message_chunk(item)
if token_like is None:
continue
chat_chunk = _to_chat_chunk(token_like)
if chat_chunk:
self._event_ch.send_nowait(chat_chunk)
def _chat_ctx_to_state(self) -> dict[str, Any]:
"""Convert chat context to langgraph input"""
messages: list[AIMessage | HumanMessage | SystemMessage] = []
for msg in self._chat_ctx.messages():
content = msg.raw_text_content
if content:
if msg.role == "assistant":
messages.append(AIMessage(content=content, id=msg.id))
elif msg.role == "user":
messages.append(HumanMessage(content=content, id=msg.id))
elif msg.role in ["system", "developer"]:
messages.append(SystemMessage(content=content, id=msg.id))
return {"messages": messages}
def _extract_message_chunk(item: Any) -> BaseMessageChunk | str | None:
"""
Normalize outputs from graph.astream(..., stream_mode='messages', [subgraphs]).
Expected shapes:
- (token, meta)
- (namespace, (token, meta)) # with subgraphs=True
- (mode, (token, meta)) # future-friendly
- (namespace, mode, (token, meta)) # future-friendly
Also tolerate direct token-like values for robustness.
"""
# Already a token-like thing?
if isinstance(item, (BaseMessageChunk, str)):
return item
if not isinstance(item, tuple):
return None
# token is usually BaseMessageChunk, but could be a str
# (token, meta)
if len(item) == 2 and not isinstance(item[1], tuple):
token, _meta = item
return token # type: ignore
# (namespace, (token, meta)) OR (mode, (token, meta))
if len(item) == 2 and isinstance(item[1], tuple):
inner = item[1]
if len(inner) == 2:
token, _meta = inner
return token # type: ignore
# (namespace, mode, (token, meta))
if len(item) == 3 and isinstance(item[2], tuple):
inner = item[2]
if len(inner) == 2:
token, _meta = inner
return token # type: ignore
return None
def _to_chat_chunk(msg: str | Any) -> llm.ChatChunk | None:
message_id = utils.shortuuid("LC_")
content: str | None = None
if isinstance(msg, str):
content = msg
elif isinstance(msg, BaseMessageChunk):
content = msg.text
if getattr(msg, "id", None):
message_id = msg.id # type: ignore
elif isinstance(msg, dict):
raw = msg.get("content")
if isinstance(raw, str):
content = raw
elif hasattr(msg, "content"):
raw = msg.content
if isinstance(raw, str):
content = raw
if not content:
return None
return llm.ChatChunk(
id=message_id,
delta=llm.ChoiceDelta(
role="assistant",
content=content,
),
)