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

1204 lines
46 KiB
Python

from __future__ import annotations
import asyncio
import struct
import time
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal
from google.protobuf.duration_pb2 import Duration
from google.protobuf.timestamp_pb2 import Timestamp
from livekit import rtc
from livekit.protocol.agent_pb import agent_session as agent_pb
from .. import llm, utils
from ..llm import (
AgentConfigUpdate,
AgentHandoff,
ChatMessage,
FunctionCall,
FunctionCallOutput,
FunctionTool,
RawFunctionTool,
Toolset,
)
from ..llm.chat_context import Instructions
from ..log import logger
from ..metrics import (
AgentSessionUsage,
EOTModelUsage,
InterruptionModelUsage,
LLMModelUsage,
STTModelUsage,
TTSModelUsage,
)
from ..version import __version__
from ..voice.amd import AMDCategory, AMDPredictionEvent
from .events import (
AgentState,
AgentStateChangedEvent,
ConversationItemAddedEvent,
EotPredictionEvent,
ErrorEvent,
FunctionToolsExecutedEvent,
SessionUsageUpdatedEvent,
ToolCallEnded,
ToolCallStarted,
ToolCallUpdated,
ToolExecutionUpdatedEvent,
ToolReplyUpdated,
UserInputTranscribedEvent,
UserState,
UserStateChangedEvent,
)
from .run_result import RunResult
if TYPE_CHECKING:
from ..cli.tcp_console import TcpAudioInput, TcpAudioOutput
from ..inference.interruption import OverlappingSpeechEvent
from .agent_session import AgentSession, AgentSessionOptions
TOPIC_SESSION_MESSAGES = "lk.agent.session"
class SessionTransport(ABC):
@abstractmethod
async def start(self) -> None: ...
@abstractmethod
async def send_message(self, msg: agent_pb.AgentSessionMessage) -> None: ...
@abstractmethod
async def close(self) -> None: ...
@abstractmethod
def __aiter__(self) -> AsyncIterator[agent_pb.AgentSessionMessage]: ...
@abstractmethod
async def __anext__(self) -> agent_pb.AgentSessionMessage: ...
class RoomSessionTransport(SessionTransport):
def __init__(self, room: rtc.Room, remote_identity: str | None = None) -> None:
self._room = room
self._remote_identity = remote_identity
self._recv_ch: utils.aio.Chan[agent_pb.AgentSessionMessage] = utils.aio.Chan()
self._handler_registered = False
self._tasks: set[asyncio.Task[None]] = set()
@property
def remote_identity(self) -> str | None:
return self._remote_identity
@remote_identity.setter
def remote_identity(self, value: str | None) -> None:
self._remote_identity = value
async def start(self) -> None:
if self._handler_registered:
return
self._room.register_byte_stream_handler(TOPIC_SESSION_MESSAGES, self._on_byte_stream)
self._handler_registered = True
def _on_byte_stream(self, reader: rtc.ByteStreamReader, participant_identity: str) -> None:
if self._remote_identity and participant_identity != self._remote_identity:
return
task = asyncio.create_task(self._read_stream(reader))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _read_stream(self, reader: rtc.ByteStreamReader) -> None:
try:
chunks: list[bytes] = []
async for chunk in reader:
chunks.append(chunk)
data = b"".join(chunks)
msg = agent_pb.AgentSessionMessage()
msg.ParseFromString(data)
self._recv_ch.send_nowait(msg)
except utils.aio.ChanClosed:
pass
except Exception as e:
logger.warning("failed to read binary stream message", exc_info=e)
async def send_message(self, msg: agent_pb.AgentSessionMessage) -> None:
if self._recv_ch.closed or not self._room.isconnected():
return
try:
data = msg.SerializeToString()
dest = [self._remote_identity] if self._remote_identity else None
writer = await self._room.local_participant.stream_bytes(
name=utils.shortuuid("AS_"),
topic=TOPIC_SESSION_MESSAGES,
destination_identities=dest,
)
await writer.write(data)
await writer.aclose()
except Exception as e:
logger.warning("failed to send binary stream message: %s", e)
async def close(self) -> None:
if self._recv_ch.closed:
return
self._recv_ch.close()
await utils.aio.cancel_and_wait(*self._tasks)
self._tasks.clear()
if self._handler_registered:
try:
self._room.unregister_byte_stream_handler(TOPIC_SESSION_MESSAGES)
except (ValueError, AttributeError):
pass
self._handler_registered = False
def __aiter__(self) -> AsyncIterator[agent_pb.AgentSessionMessage]:
return self._recv_ch.__aiter__()
async def __anext__(self) -> agent_pb.AgentSessionMessage:
return await self._recv_ch.__anext__()
_TCP_HEADER_SIZE = 4
_TCP_MAX_MESSAGE_SIZE = 1 << 20
class TcpSessionTransport(SessionTransport):
def __init__(self, host: str, port: int) -> None:
self._host = host
self._port = port
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._closed = False
self._loop: asyncio.AbstractEventLoop | None = None
async def start(self) -> None:
reader, writer = await asyncio.open_connection(self._host, self._port)
sock = writer.transport.get_extra_info("socket")
if sock is not None:
import socket
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self._reader = reader
self._writer = writer
self._loop = asyncio.get_running_loop()
async def send_message(self, msg: agent_pb.AgentSessionMessage) -> None:
if self._closed or self._writer is None:
return
data = msg.SerializeToString()
header = struct.pack(">I", len(data))
self._writer.write(header + data)
if self._writer.transport.get_write_buffer_size() > 64 * 1024:
await self._writer.drain()
def send_message_threadsafe(self, msg: agent_pb.AgentSessionMessage) -> None:
if self._closed or self._writer is None or self._loop is None:
return
data = msg.SerializeToString()
payload = struct.pack(">I", len(data)) + data
self._loop.call_soon_threadsafe(self._writer.write, payload)
async def close(self) -> None:
if self._closed:
return
self._closed = True
if self._writer is not None:
try:
self._writer.close()
await self._writer.wait_closed()
except (ConnectionError, OSError):
pass
def __aiter__(self) -> AsyncIterator[agent_pb.AgentSessionMessage]:
return self
async def __anext__(self) -> agent_pb.AgentSessionMessage:
if self._closed or self._reader is None:
raise StopAsyncIteration
try:
header = await self._reader.readexactly(_TCP_HEADER_SIZE)
except (asyncio.IncompleteReadError, ConnectionError, OSError):
raise StopAsyncIteration from None
length = struct.unpack(">I", header)[0]
if length > _TCP_MAX_MESSAGE_SIZE:
logger.error("TCP message too large: %d bytes", length)
raise StopAsyncIteration
try:
data = await self._reader.readexactly(length)
except (asyncio.IncompleteReadError, ConnectionError, OSError):
raise StopAsyncIteration from None
msg = agent_pb.AgentSessionMessage()
msg.ParseFromString(data)
return msg
_AGENT_STATE_MAP: dict[AgentState, agent_pb.AgentState] = {
"initializing": agent_pb.AS_INITIALIZING,
"idle": agent_pb.AS_IDLE,
"listening": agent_pb.AS_LISTENING,
"thinking": agent_pb.AS_THINKING,
"speaking": agent_pb.AS_SPEAKING,
}
_USER_STATE_MAP: dict[UserState, agent_pb.UserState] = {
"speaking": agent_pb.US_SPEAKING,
"listening": agent_pb.US_LISTENING,
"away": agent_pb.US_AWAY,
}
_METRICS_FIELDS = (
"transcription_delay",
"end_of_turn_delay",
"on_user_turn_completed_delay",
"llm_node_ttft",
"tts_node_ttfb",
"e2e_latency",
)
_TOOL_CALL_STATUS_MAP: dict[str, agent_pb.ToolCallStatus] = {
"done": agent_pb.TC_DONE,
"error": agent_pb.TC_ERROR,
"cancelled": agent_pb.TC_CANCELLED,
}
_TOOL_REPLY_STATUS_MAP: dict[str, agent_pb.ToolReplyStatus] = {
"scheduled": agent_pb.TR_SCHEDULED,
"completed": agent_pb.TR_COMPLETED,
"interrupted": agent_pb.TR_INTERRUPTED,
"skipped": agent_pb.TR_SKIPPED,
}
_AMD_CATEGORY_MAP: dict[AMDCategory, agent_pb.AmdCategory] = {
AMDCategory.HUMAN: agent_pb.AmdCategory.AMD_HUMAN,
AMDCategory.MACHINE_IVR: agent_pb.AmdCategory.AMD_MACHINE_IVR,
AMDCategory.MACHINE_VM: agent_pb.AmdCategory.AMD_MACHINE_VM,
AMDCategory.MACHINE_UNAVAILABLE: agent_pb.AmdCategory.AMD_MACHINE_UNAVAILABLE,
AMDCategory.UNCERTAIN: agent_pb.AmdCategory.AMD_UNCERTAIN,
}
def _tool_names(tools: Sequence[llm.Tool | Toolset]) -> list[str]:
result: list[str] = []
for tool in tools:
if isinstance(tool, FunctionTool | RawFunctionTool):
result.append(tool.info.name)
elif isinstance(tool, Toolset):
result.extend(_tool_names(tool.tools))
return result
def _metrics_to_proto(metrics: Mapping[str, Any] | None) -> agent_pb.MetricsReport:
if not metrics:
return agent_pb.MetricsReport()
kwargs = {k: metrics[k] for k in _METRICS_FIELDS if k in metrics}
return agent_pb.MetricsReport(**kwargs)
def _chat_item_to_proto(item: llm.ChatItem) -> agent_pb.ChatContext.ChatItem:
if isinstance(item, ChatMessage):
role_map = {
"developer": agent_pb.DEVELOPER,
"system": agent_pb.SYSTEM,
"user": agent_pb.USER,
"assistant": agent_pb.ASSISTANT,
}
pb_role = role_map.get(item.role, agent_pb.ASSISTANT)
content = []
if item.raw_text_content:
content.append(agent_pb.ChatMessage.ChatContent(text=item.raw_text_content))
pb_msg = agent_pb.ChatMessage(
id=item.id,
role=pb_role,
content=content,
interrupted=item.interrupted,
metrics=_metrics_to_proto(item.metrics),
)
return agent_pb.ChatContext.ChatItem(message=pb_msg)
elif isinstance(item, FunctionCall):
return agent_pb.ChatContext.ChatItem(
function_call=agent_pb.FunctionCall(
id=item.id,
call_id=item.call_id,
name=item.name,
arguments=item.arguments,
)
)
elif isinstance(item, FunctionCallOutput):
return agent_pb.ChatContext.ChatItem(
function_call_output=agent_pb.FunctionCallOutput(
call_id=item.call_id,
output=item.output,
is_error=item.is_error,
)
)
elif isinstance(item, AgentHandoff):
return agent_pb.ChatContext.ChatItem(
agent_handoff=agent_pb.AgentHandoff(
id=item.id,
old_agent_id=item.old_agent_id,
new_agent_id=item.new_agent_id,
)
)
elif isinstance(item, AgentConfigUpdate):
return agent_pb.ChatContext.ChatItem(
agent_config_update=agent_pb.AgentConfigUpdate(
id=item.id,
instructions=str(item.instructions) if item.instructions is not None else None,
tools_added=item.tools_added or [],
tools_removed=item.tools_removed or [],
)
)
return agent_pb.ChatContext.ChatItem()
def _serialize_options(opts: AgentSessionOptions) -> dict[str, str]:
return {
"endpointing": str(dict(opts.endpointing)),
"interruption": str(dict(opts.interruption)),
"max_tool_steps": str(opts.max_tool_steps),
"user_away_timeout": str(opts.user_away_timeout),
"preemptive_generation": str(dict(opts.preemptive_generation)),
"min_consecutive_speech_delay": str(opts.min_consecutive_speech_delay),
"use_tts_aligned_transcript": str(opts.use_tts_aligned_transcript),
"ivr_detection": str(opts.ivr_detection),
}
class SessionHost:
def __init__(
self,
transport: SessionTransport,
audio_input: TcpAudioInput | None = None,
audio_output: TcpAudioOutput | None = None,
) -> None:
self._transport = transport
self._audio_input = audio_input
self._audio_output = audio_output
self._started = False
self._recv_task: asyncio.Task[None] | None = None
self._tasks = utils.aio.TaskSet()
self._session: AgentSession | None = None
self._events_registered = False
def register_session(self, session: AgentSession) -> None:
self._session = session
if not self._events_registered:
self._events_registered = True
session.on("agent_state_changed", self._on_agent_state_changed)
session.on("user_state_changed", self._on_user_state_changed)
session.on("conversation_item_added", self._on_conversation_item_added)
session.on("user_input_transcribed", self._on_user_input_transcribed)
session.on("function_tools_executed", self._on_function_tools_executed)
session.on("tool_execution_updated", self._on_tool_execution_updated)
session.on("session_usage_updated", self._on_session_usage_updated)
session.on("overlapping_speech", self._on_overlapping_speech)
session.on("error", self._on_error)
session.on("debug_message", self._on_debug_message)
async def start(self) -> None:
if self._started:
return
self._started = True
await self._transport.start()
self._recv_task = asyncio.create_task(self._recv_loop())
async def aclose(self) -> None:
if not self._started:
return
self._started = False
if self._session and self._events_registered:
self._events_registered = False
self._session.off("agent_state_changed", self._on_agent_state_changed)
self._session.off("user_state_changed", self._on_user_state_changed)
self._session.off("conversation_item_added", self._on_conversation_item_added)
self._session.off("user_input_transcribed", self._on_user_input_transcribed)
self._session.off("function_tools_executed", self._on_function_tools_executed)
self._session.off("tool_execution_updated", self._on_tool_execution_updated)
self._session.off("session_usage_updated", self._on_session_usage_updated)
self._session.off("overlapping_speech", self._on_overlapping_speech)
self._session.off("error", self._on_error)
self._session.off("debug_message", self._on_debug_message)
if self._recv_task:
await utils.aio.cancel_and_wait(self._recv_task)
await utils.aio.cancel_and_wait(*self._tasks.tasks)
await self._transport.close()
async def _recv_loop(self) -> None:
try:
async for msg in self._transport:
if msg.HasField("request"):
if self._session is not None:
self._tasks.create_task(self._handle_request_safe(msg.request))
else:
msg_type = msg.WhichOneof("message")
if msg_type:
self._dispatch_transport_message(msg_type, msg)
except asyncio.CancelledError:
pass
except Exception:
logger.warning("error processing session message", exc_info=True)
def _dispatch_transport_message(self, msg_type: str, msg: agent_pb.AgentSessionMessage) -> None:
if msg_type == "audio_input" and self._audio_input is not None:
self._audio_input.push_frame(msg.audio_input)
elif msg_type == "audio_playback_finished" and self._audio_output is not None:
self._audio_output.notify_playout_finished()
def _send_event(
self, event: agent_pb.AgentSessionEvent, created_at: float | None = None
) -> None:
ts = Timestamp()
ts.FromNanoseconds(int((created_at if created_at is not None else time.time()) * 1e9))
event.created_at.CopyFrom(ts)
msg = agent_pb.AgentSessionMessage(event=event)
self._tasks.create_task(self._transport.send_message(msg))
def _on_agent_state_changed(self, event: AgentStateChangedEvent) -> None:
old_pb = _AGENT_STATE_MAP.get(event.old_state, agent_pb.AS_IDLE)
new_pb = _AGENT_STATE_MAP.get(event.new_state, agent_pb.AS_IDLE)
self._send_event(
agent_pb.AgentSessionEvent(
agent_state_changed=agent_pb.AgentSessionEvent.AgentStateChanged(
old_state=old_pb,
new_state=new_pb,
)
)
)
def _on_user_state_changed(self, event: UserStateChangedEvent) -> None:
old_pb = _USER_STATE_MAP.get(event.old_state, agent_pb.US_LISTENING)
new_pb = _USER_STATE_MAP.get(event.new_state, agent_pb.US_LISTENING)
# use the original timestamp which is adjusted for VAD latency
self._send_event(
agent_pb.AgentSessionEvent(
user_state_changed=agent_pb.AgentSessionEvent.UserStateChanged(
old_state=old_pb,
new_state=new_pb,
)
),
created_at=event.created_at,
)
def _on_user_input_transcribed(self, event: UserInputTranscribedEvent) -> None:
self._send_event(
agent_pb.AgentSessionEvent(
user_input_transcribed=agent_pb.AgentSessionEvent.UserInputTranscribed(
transcript=event.transcript,
is_final=event.is_final,
)
)
)
def _on_conversation_item_added(self, event: ConversationItemAddedEvent) -> None:
if not isinstance(
event.item,
ChatMessage | FunctionCall | FunctionCallOutput | AgentHandoff | AgentConfigUpdate,
):
return
chat_item = _chat_item_to_proto(event.item)
self._send_event(
agent_pb.AgentSessionEvent(
conversation_item_added=agent_pb.AgentSessionEvent.ConversationItemAdded(
item=chat_item,
)
)
)
def _on_function_tools_executed(self, event: FunctionToolsExecutedEvent) -> None:
pb_calls = [
agent_pb.FunctionCall(
name=fc.name,
arguments=fc.arguments,
call_id=fc.call_id,
)
for fc in event.function_calls
]
pb_outputs = [
agent_pb.FunctionCallOutput(
call_id=fco.call_id,
output=fco.output,
is_error=fco.is_error,
)
for fco in event.function_call_outputs
if fco is not None
]
self._send_event(
agent_pb.AgentSessionEvent(
function_tools_executed=agent_pb.AgentSessionEvent.FunctionToolsExecuted(
function_calls=pb_calls,
function_call_outputs=pb_outputs,
)
)
)
def _on_tool_execution_updated(self, event: ToolExecutionUpdatedEvent) -> None:
pb = agent_pb.AgentSessionEvent.ToolExecutionUpdated
updated: agent_pb.AgentSessionEvent.ToolExecutionUpdated
if isinstance(event.update, ToolCallStarted):
fc = event.update.function_call
updated = pb(
started=pb.Started(
function_call=agent_pb.FunctionCall(
id=fc.id,
call_id=fc.call_id,
name=fc.name,
arguments=fc.arguments,
)
)
)
elif isinstance(event.update, ToolCallUpdated):
updated = pb(
call_updated=pb.CallUpdated(
id=event.update.id,
call_id=event.update.call_id,
message=event.update.message,
)
)
elif isinstance(event.update, ToolCallEnded):
ended = pb.Ended(
id=event.update.id,
call_id=event.update.call_id,
status=_TOOL_CALL_STATUS_MAP[event.update.status],
)
if event.update.message is not None:
ended.message = event.update.message
updated = pb(ended=ended)
elif isinstance(event.update, ToolReplyUpdated):
updated = pb(
reply_updated=pb.ReplyUpdated(
update_ids=event.update.update_ids,
status=_TOOL_REPLY_STATUS_MAP[event.update.status],
speech_id=event.update.speech_id,
)
)
else:
return
self._send_event(
agent_pb.AgentSessionEvent(tool_execution_updated=updated),
created_at=event.created_at,
)
def _on_overlapping_speech(self, event: OverlappingSpeechEvent) -> None:
detected_at = Timestamp()
detected_at.FromNanoseconds(int(event.detected_at * 1e9))
overlap_started_at: Timestamp | None = None
if event.overlap_started_at is not None:
overlap_started_at = Timestamp()
overlap_started_at.FromNanoseconds(int(event.overlap_started_at * 1e9))
pb = agent_pb.AgentSessionEvent.OverlappingSpeech(
is_interruption=event.is_interruption,
detection_delay=event.detection_delay,
detected_at=detected_at,
)
if overlap_started_at is not None:
pb.overlap_started_at.CopyFrom(overlap_started_at)
self._send_event(agent_pb.AgentSessionEvent(overlapping_speech=pb))
def _on_amd_prediction(self, event: AMDPredictionEvent) -> None:
speech_duration = Duration()
speech_duration.FromNanoseconds(int(event.speech_duration * 1e9))
delay = Duration()
delay.FromNanoseconds(int(event.delay * 1e9))
self._send_event(
agent_pb.AgentSessionEvent(
amd_prediction=agent_pb.AgentSessionEvent.AmdPrediction(
speech_duration=speech_duration,
delay=delay,
category=_AMD_CATEGORY_MAP[event.category],
reason=event.reason,
transcript=event.transcript,
)
)
)
def _on_eot_prediction(self, event: EotPredictionEvent) -> None:
inference_duration = Duration()
inference_duration.FromNanoseconds(int(event.inference_duration * 1e9))
delay = Duration()
delay.FromNanoseconds(int(event.delay * 1e9))
self._send_event(
agent_pb.AgentSessionEvent(
eot_prediction=agent_pb.AgentSessionEvent.EotPrediction(
probability=event.probability,
threshold=event.threshold,
inference_duration=inference_duration,
delay=delay,
)
)
)
def _on_session_usage_updated(self, event: SessionUsageUpdatedEvent) -> None:
self._send_event(
agent_pb.AgentSessionEvent(
session_usage_updated=agent_pb.AgentSessionEvent.SessionUsageUpdated(
usage=_session_usage_to_proto(event.usage),
)
)
)
def _on_error(self, event: ErrorEvent) -> None:
self._send_event(
agent_pb.AgentSessionEvent(
error=agent_pb.AgentSessionEvent.Error(
message=str(event.error) if event.error else "Unknown error",
)
)
)
def _on_debug_message(self, event: agent_pb.DebugMessage) -> None:
self._send_event(agent_pb.AgentSessionEvent(debug_message=event))
async def _handle_request_safe(self, req: agent_pb.SessionRequest) -> None:
try:
await self._handle_request(req)
except Exception:
logger.warning(
"error handling session request",
exc_info=True,
extra={"request_id": req.request_id},
)
try:
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
error="internal error",
)
)
await self._transport.send_message(resp)
except Exception:
pass
async def _handle_request(self, req: agent_pb.SessionRequest) -> None:
assert self._session is not None
if req.HasField("ping"):
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
pong=agent_pb.SessionResponse.Pong(),
)
)
await self._transport.send_message(resp)
elif req.HasField("get_chat_history"):
items = [_chat_item_to_proto(item) for item in self._session.history.items]
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
get_chat_history=agent_pb.SessionResponse.GetChatHistoryResponse(
items=items,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("get_agent_info"):
agent = self._session.current_agent
items = [_chat_item_to_proto(item) for item in agent.chat_ctx.items]
# collapse modality variants for the report; audio-first matches the
# update_instructions default for voice sessions
agent_instructions = (
agent.instructions.render(modality="audio")
if isinstance(agent.instructions, Instructions)
else agent.instructions
)
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
get_agent_info=agent_pb.SessionResponse.GetAgentInfoResponse(
id=agent.id,
instructions=agent_instructions,
tools=_tool_names(agent.tools),
chat_ctx=items,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("run_input"):
items_list: list[agent_pb.ChatContext.ChatItem] = []
error: str | None = None
text = req.run_input.text
if not text:
error = "empty run_input text"
else:
try:
await self._session.interrupt(force=True)
except RuntimeError:
pass
try:
result: RunResult[None] = self._session.run(user_input=text)
await result
items_list = [_chat_item_to_proto(ev.item) for ev in result.events]
except Exception as e:
error = str(e)
if not items_list and not error:
error = "agent produced no response items"
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
error=error,
run_input=agent_pb.SessionResponse.RunInputResponse(
items=items_list,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("get_session_state"):
agent = self._session.current_agent
created_at = Timestamp()
started_at = self._session._started_at or time.time()
created_at.FromNanoseconds(int(started_at * 1e9))
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
get_session_state=agent_pb.SessionResponse.GetSessionStateResponse(
agent_state=_AGENT_STATE_MAP.get(
self._session.agent_state,
agent_pb.AS_IDLE,
),
user_state=_USER_STATE_MAP.get(
self._session.user_state,
agent_pb.US_LISTENING,
),
agent_id=agent.id,
options=_serialize_options(self._session.options),
created_at=created_at,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("get_rtc_stats"):
from google.protobuf.struct_pb2 import Struct
rtc_stats = (
await self._session._room_io.room.get_rtc_stats()
if self._session._room_io is not None
else None
)
publisher_stats: list[Struct] = []
subscriber_stats: list[Struct] = []
if rtc_stats:
from google.protobuf.json_format import MessageToDict
for s in rtc_stats.publisher_stats:
d = MessageToDict(s)
st = Struct()
st.update(d)
publisher_stats.append(st)
for s in rtc_stats.subscriber_stats:
d = MessageToDict(s)
st = Struct()
st.update(d)
subscriber_stats.append(st)
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
get_rtc_stats=agent_pb.SessionResponse.GetRTCStatsResponse(
publisher_stats=publisher_stats,
subscriber_stats=subscriber_stats,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("get_session_usage"):
created_at = Timestamp()
created_at.FromNanoseconds(int(time.time() * 1e9))
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
get_session_usage=agent_pb.SessionResponse.GetSessionUsageResponse(
usage=_session_usage_to_proto(self._session.usage),
created_at=created_at,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("get_framework_info"):
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
get_framework_info=agent_pb.SessionResponse.GetFrameworkInfoResponse(
sdk="python",
sdk_version=__version__,
),
)
)
await self._transport.send_message(resp)
elif req.HasField("update_io"):
# Honor the remote control's mute/unmute toggles for audio /
# video / transcription. Only fields actually set in the proto
# are applied (presence-tracked booleans), so the client can
# send a partial update without clobbering the other channels.
io = req.update_io
input_io = self._session.input
output_io = self._session.output
if io.HasField("input"):
if io.input.HasField("audio_enabled"):
input_io.set_audio_enabled(io.input.audio_enabled)
if io.input.HasField("video_enabled"):
input_io.set_video_enabled(io.input.video_enabled)
if io.HasField("output"):
if io.output.HasField("audio_enabled"):
output_io.set_audio_enabled(io.output.audio_enabled)
if io.output.HasField("video_enabled"):
output_io.set_video_enabled(io.output.video_enabled)
if io.output.HasField("transcription_enabled"):
output_io.set_transcription_enabled(io.output.transcription_enabled)
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
update_io=agent_pb.SessionResponse.UpdateIOResponse(),
)
)
await self._transport.send_message(resp)
elif req.HasField("finalize_simulation"):
# The simulator's verdict is passed in so on_simulation_end can read it
# (ctx.simulator_verdict); the agent records its OWN verdict via
# ctx.success()/fail(). Both are reported; this is not an override.
user_verdict: (
agent_pb.SessionResponse.FinalizeSimulationResponse.SimulationVerdict | None
) = None # noqa: E501
sim_error: str | None = None
try:
from livekit.protocol import agent_simulation as sim_pb
from ..job import get_job_context
from ..simulation import SimulationVerdict
jc = get_job_context(required=False)
sim_ctx = jc.simulation_context() if jc is not None else None
if sim_ctx is not None:
sim_ctx._begin_finalize(
simulator_verdict=SimulationVerdict(
success=req.finalize_simulation.provisional_success,
reason=req.finalize_simulation.provisional_reason,
),
run=sim_pb.SimulationRun(id=sim_ctx._dispatch.simulation_run_id),
job=None,
)
fnc = jc._simulation_end_fnc if jc is not None else None
if fnc is not None:
cb_res = fnc(sim_ctx)
if asyncio.iscoroutine(cb_res):
await cb_res
if (uv := sim_ctx.user_verdict) is not None:
user_verdict = (
agent_pb.SessionResponse.FinalizeSimulationResponse.SimulationVerdict(
success=uv.success, reason=uv.reason
)
)
except Exception as e:
sim_error = str(e)
logger.exception("error while executing the on_simulation_end callback")
resp = agent_pb.AgentSessionMessage(
response=agent_pb.SessionResponse(
request_id=req.request_id,
error=sim_error,
finalize_simulation=agent_pb.SessionResponse.FinalizeSimulationResponse(
user_verdict=user_verdict,
),
)
)
await self._transport.send_message(resp)
def _session_usage_to_proto(usage: AgentSessionUsage) -> agent_pb.AgentSessionUsage:
model_usages: list[agent_pb.ModelUsage] = []
for mu in usage.model_usage:
if isinstance(mu, LLMModelUsage):
model_usages.append(
agent_pb.ModelUsage(
llm=agent_pb.LLMModelUsage(
provider=mu.provider,
model=mu.model,
input_tokens=mu.input_tokens,
input_cached_tokens=mu.input_cached_tokens,
input_audio_tokens=mu.input_audio_tokens,
input_cached_audio_tokens=mu.input_cached_audio_tokens,
input_text_tokens=mu.input_text_tokens,
input_cached_text_tokens=mu.input_cached_text_tokens,
input_image_tokens=mu.input_image_tokens,
input_cached_image_tokens=mu.input_cached_image_tokens,
output_tokens=mu.output_tokens,
output_audio_tokens=mu.output_audio_tokens,
output_text_tokens=mu.output_text_tokens,
session_duration=mu.session_duration,
)
)
)
elif isinstance(mu, TTSModelUsage):
model_usages.append(
agent_pb.ModelUsage(
tts=agent_pb.TTSModelUsage(
provider=mu.provider,
model=mu.model,
input_tokens=mu.input_tokens,
output_tokens=mu.output_tokens,
characters_count=mu.characters_count,
audio_duration=mu.audio_duration,
)
)
)
elif isinstance(mu, STTModelUsage):
model_usages.append(
agent_pb.ModelUsage(
stt=agent_pb.STTModelUsage(
provider=mu.provider,
model=mu.model,
input_tokens=mu.input_tokens,
output_tokens=mu.output_tokens,
audio_duration=mu.audio_duration,
)
)
)
elif isinstance(mu, InterruptionModelUsage):
model_usages.append(
agent_pb.ModelUsage(
interruption=agent_pb.InterruptionModelUsage(
provider=mu.provider,
model=mu.model,
total_requests=mu.total_requests,
)
)
)
elif isinstance(mu, EOTModelUsage):
model_usages.append(
agent_pb.ModelUsage(
eot=agent_pb.EotModelUsage(
provider=mu.provider,
model=mu.model,
total_requests=mu.total_requests,
)
)
)
return agent_pb.AgentSessionUsage(model_usage=model_usages)
RemoteSessionEventTypes = Literal[
"agent_state_changed",
"user_state_changed",
"conversation_item_added",
"user_input_transcribed",
"function_tools_executed",
"tool_execution_updated",
"session_usage_updated",
"error",
]
class RemoteSession(rtc.EventEmitter[RemoteSessionEventTypes]):
def __init__(self, transport: SessionTransport) -> None:
super().__init__()
self._transport = transport
self._started = False
self._pending_requests: dict[str, asyncio.Future[agent_pb.SessionResponse]] = {}
self._recv_task: asyncio.Task[None] | None = None
@classmethod
def from_room(cls, room: rtc.Room, agent_identity: str) -> RemoteSession:
transport = RoomSessionTransport(room, agent_identity)
return cls(transport)
async def start(self) -> None:
if self._started:
return
self._started = True
await self._transport.start()
self._recv_task = asyncio.create_task(self._recv_loop())
async def aclose(self) -> None:
if not self._started:
return
self._started = False
for future in self._pending_requests.values():
future.cancel()
self._pending_requests.clear()
if self._recv_task:
await utils.aio.cancel_and_wait(self._recv_task)
await self._transport.close()
async def _recv_loop(self) -> None:
try:
async for msg in self._transport:
if msg.HasField("response"):
self._dispatch_response(msg.response)
elif msg.HasField("event"):
event_field = msg.event.WhichOneof("event")
if event_field:
self.emit(event_field, msg.event)
except asyncio.CancelledError:
pass
except Exception:
logger.warning("error processing session message", exc_info=True)
def _dispatch_response(self, response: agent_pb.SessionResponse) -> None:
future = self._pending_requests.pop(response.request_id, None)
if future and not future.done():
future.set_result(response)
async def _send_request(
self,
request: agent_pb.SessionRequest,
timeout: float = 60.0,
) -> agent_pb.SessionResponse:
req_type = request.WhichOneof("request")
future: asyncio.Future[agent_pb.SessionResponse] = asyncio.Future()
self._pending_requests[request.request_id] = future
try:
msg = agent_pb.AgentSessionMessage(request=request)
await self._transport.send_message(msg)
resp = await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending_requests.pop(request.request_id, None)
logger.warning(
"remote session request timed out",
extra={"request_id": request.request_id, "type": req_type, "timeout": timeout},
)
raise
except Exception:
self._pending_requests.pop(request.request_id, None)
raise
if resp.error:
raise RuntimeError(f"session request {req_type} failed: {resp.error}")
return resp
async def wait_for_ready(self, timeout: float = 5.0, retry_interval: float = 0.5) -> None:
deadline = asyncio.get_event_loop().time() + timeout
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
raise TimeoutError("wait_for_ready timed out")
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
ping=agent_pb.SessionRequest.Ping(),
)
try:
await self._send_request(req, timeout=min(retry_interval, remaining))
return
except (TimeoutError, asyncio.TimeoutError):
if asyncio.get_event_loop().time() >= deadline:
raise TimeoutError("wait_for_ready timed out") from None
async def get_chat_history(self) -> agent_pb.SessionResponse.GetChatHistoryResponse:
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
get_chat_history=agent_pb.SessionRequest.GetChatHistory(),
)
resp = await self._send_request(req)
return resp.get_chat_history
async def get_agent_info(self) -> agent_pb.SessionResponse.GetAgentInfoResponse:
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
get_agent_info=agent_pb.SessionRequest.GetAgentInfo(),
)
resp = await self._send_request(req)
return resp.get_agent_info
async def get_session_state(self) -> agent_pb.SessionResponse.GetSessionStateResponse:
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
get_session_state=agent_pb.SessionRequest.GetSessionState(),
)
resp = await self._send_request(req)
return resp.get_session_state
async def run(
self, text: str, timeout: float = 60.0
) -> agent_pb.SessionResponse.RunInputResponse:
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
run_input=agent_pb.SessionRequest.RunInput(text=text),
)
resp = await self._send_request(req, timeout=timeout)
return resp.run_input
async def update_io(
self,
*,
input_audio_enabled: bool | None = None,
input_video_enabled: bool | None = None,
output_audio_enabled: bool | None = None,
output_video_enabled: bool | None = None,
output_transcription_enabled: bool | None = None,
timeout: float = 60.0,
) -> agent_pb.SessionResponse.UpdateIOResponse:
"""Toggle the agent's I/O channels remotely.
Only the channels passed (non-None) are applied; the rest are left
untouched. Simulators use this to disable the agent's audio I/O instead
of relying on a room attribute.
"""
update = agent_pb.SessionRequest.UpdateIO()
if input_audio_enabled is not None:
update.input.audio_enabled = input_audio_enabled
if input_video_enabled is not None:
update.input.video_enabled = input_video_enabled
if output_audio_enabled is not None:
update.output.audio_enabled = output_audio_enabled
if output_video_enabled is not None:
update.output.video_enabled = output_video_enabled
if output_transcription_enabled is not None:
update.output.transcription_enabled = output_transcription_enabled
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
update_io=update,
)
resp = await self._send_request(req, timeout=timeout)
return resp.update_io
async def finalize_simulation(
self,
*,
provisional_success: bool,
provisional_reason: str = "",
timeout: float = 60.0,
) -> agent_pb.SessionResponse.FinalizeSimulationResponse:
"""Hand the agent under test the simulator's provisional verdict and return the
agent's own verdict from its on_simulation_end callback. The response's
``user_verdict`` is unset when the agent has no handler (or times out) or sets
no verdict of its own; both verdicts are reported, neither overrides the other."""
req = agent_pb.SessionRequest(
request_id=utils.shortuuid("req_"),
finalize_simulation=agent_pb.SessionRequest.FinalizeSimulation(
provisional_success=provisional_success,
provisional_reason=provisional_reason,
),
)
resp = await self._send_request(req, timeout=timeout)
return resp.finalize_simulation