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

1921 lines
63 KiB
Python

"""Deprecated rich Python CLI (``console``/``dev``/``connect``/``download-files``).
These commands have moved to the LiveKit CLI (``lk agent ...``), which drives the
thin interface in ``cli.py`` over a subprocess. This module is kept for backwards
compatibility and will be removed in a future release; ``cli.run_app`` routes the
legacy commands here after emitting a deprecation warning.
"""
from __future__ import annotations
import asyncio
import contextvars
import datetime
import enum
import hashlib
import json
import logging
import os
import pathlib
import re
import signal
import sys
import textwrap
import threading
import time
import traceback
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from time import time as _wall_time
from types import FrameType
from typing import TYPE_CHECKING, Annotated, Any, Literal
import numpy as np
import typer
from rich.columns import Columns
from rich.console import Console, ConsoleRenderable, Group, RenderableType
from rich.live import Live
from rich.segment import Segment
from rich.spinner import Spinner
from rich.style import Style
from rich.table import Column, Table
from rich.text import Text
from rich.theme import Theme
from livekit import api, rtc
from .. import llm
from .._exceptions import CLIError
from ..job import JobExecutorType
from ..log import logger
from ..plugin import Plugin
from ..utils import aio, shortuuid
from ..voice import AgentSession, io
from ..voice.run_result import RunEvent
from ..voice.transcription import TranscriptSynchronizer
from ..worker import AgentServer, ServerEnvOption, WorkerOptions
from . import cli as _cli, proto
from .log import JsonFormatter, _merge_record_extra, _silence_noisy_loggers
# from .discover import get_import_data
from .readchar import key, readkey
TRACE_LOG_LEVEL = 5
if TYPE_CHECKING:
import sounddevice as sd # type: ignore
HANDLED_SIGNALS = (
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
signal.SIGTERM,
)
class _ToggleMode(Exception):
pass
class _ExitCli(BaseException):
pass
# from https://github.com/encode/uvicorn/blob/c1144fd4f130388cffc05ee17b08747ce8c1be11/uvicorn/importer.py#L9C1-L34C20
# def import_from_string(import_str: Any) -> Any:
# if not isinstance(import_str, str):
# return import_str
# module_str, _, attrs_str = import_str.partition(":")
# if not module_str or not attrs_str:
# message = 'Import string "{import_str}" must be in format "<module>:<attribute>".'
# raise RuntimeError(message.format(import_str=import_str))
# try:
# module = importlib.import_module(module_str)
# except ModuleNotFoundError as exc:
# if exc.name != module_str:
# raise exc from None
# message = 'Could not import module "{module_str}".'
# raise RuntimeError(message.format(module_str=module_str)) from None
# instance = module
# try:
# for attr_str in attrs_str.split("."):
# instance = getattr(instance, attr_str)
# except AttributeError:
# message = 'Attribute "{attrs_str}" not found in module "{module_str}".'
# raise RuntimeError(message.format(attrs_str=attrs_str, module_str=module_str)) from None
# return instance
ConsoleMode = Literal["text", "audio"]
SAMPLE_RATE = 24000
class ConsoleAudioInput(io.AudioInput):
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
super().__init__(label="Console")
self._loop = loop
self._audio_ch: aio.Chan[rtc.AudioFrame] = aio.Chan()
self._attached = True
def push_frame(self, frame: rtc.AudioFrame) -> None:
if not self._attached:
# drop frames if the input is detached
return
self._audio_ch.send_nowait(frame)
async def __anext__(self) -> rtc.AudioFrame:
return await self._audio_ch.__anext__()
def on_attached(self) -> None:
self._attached = True
def on_detached(self) -> None:
self._attached = False
class ConsoleAudioOutput(io.AudioOutput):
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
super().__init__(
label="Console",
next_in_chain=None,
sample_rate=SAMPLE_RATE,
capabilities=io.AudioOutputCapabilities(pause=True),
)
self._loop = loop
self._pushed_duration: float = 0.0
self._capture_start: float = 0.0
self._flush_task: asyncio.Task[None] | None = None
self._playback_started_fired: bool = False
self._output_buf = bytearray()
self._audio_lock = threading.Lock()
self._output_buf_empty = asyncio.Event()
self._output_buf_empty.set()
self._interrupted_ev = asyncio.Event()
self._paused_at: float | None = None
self._paused_duration: float = 0.0
# track the segment id to avoid stale async operations
self._segment_id = 0
@property
def audio_lock(self) -> threading.Lock:
return self._audio_lock
@property
def audio_buffer(self) -> bytearray:
return self._output_buf
@property
def paused(self) -> bool:
return self._paused_at is not None
def mark_output_empty(self) -> None:
self._output_buf_empty.set()
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
await super().capture_frame(frame)
if self._flush_task and not self._flush_task.done():
logger.error("capture_frame called while previous flush is in progress")
await self._flush_task
if not self._pushed_duration:
self._capture_start = time.monotonic()
self._pushed_duration += frame.duration
with self._audio_lock:
self._output_buf += frame.data # TODO: optimize
self._output_buf_empty.clear()
def flush(self) -> None:
super().flush()
if self._pushed_duration:
if self._flush_task and not self._flush_task.done():
logger.error("flush called while previous flush is in progress")
self._flush_task.cancel()
self._flush_task = asyncio.create_task(self._wait_for_playout())
def clear_buffer(self) -> None:
with self._audio_lock:
self._output_buf.clear()
self._output_buf_empty.set()
# redundant (_wait_for_playout does the same, albeit async) but defensive
self._segment_id += 1
self._playback_started_fired = False
if self._pushed_duration:
self._interrupted_ev.set()
def pause(self) -> None:
super().pause()
if self._paused_at is None:
self._paused_at = time.monotonic()
def resume(self) -> None:
super().resume()
if self._paused_at is not None:
self._paused_duration += time.monotonic() - self._paused_at
self._paused_at = None
async def _wait_for_playout(self) -> None:
async def _wait_buffered_audio() -> None:
while len(self._output_buf) > 0:
await self._output_buf_empty.wait()
await asyncio.sleep(0)
wait_for_interruption = asyncio.create_task(self._interrupted_ev.wait())
wait_for_playout = asyncio.create_task(_wait_buffered_audio())
try:
await asyncio.wait(
[wait_for_playout, wait_for_interruption],
return_when=asyncio.FIRST_COMPLETED,
)
interrupted = wait_for_interruption.done()
finally:
wait_for_playout.cancel()
wait_for_interruption.cancel()
if self._paused_at is not None:
self._paused_duration += time.monotonic() - self._paused_at
self._paused_at = None
if interrupted:
played_duration = time.monotonic() - self._capture_start - self._paused_duration
played_duration = min(max(0, played_duration), self._pushed_duration)
else:
played_duration = self._pushed_duration
self.on_playback_finished(playback_position=played_duration, interrupted=interrupted)
self._pushed_duration = 0.0
self._paused_at = None
self._paused_duration = 0.0
self._interrupted_ev.clear()
with self._audio_lock:
self._output_buf_empty.set()
self._playback_started_fired = False
self._segment_id += 1
def _maybe_mark_playback_started(self) -> None:
"""Mark the playback as started if it hasn't been already. Must be called under ``audio_lock``."""
if self._playback_started_fired:
return
self._playback_started_fired = True
t = _wall_time()
segment_id = self._segment_id
self._loop.call_soon_threadsafe(
lambda: self._on_playback_started(created_at=t, segment_id=segment_id)
)
def _on_playback_started(self, *, created_at: float, segment_id: int) -> None:
if self._segment_id != segment_id:
return
self.on_playback_started(created_at=created_at)
class AgentsConsole:
_instance: AgentsConsole | None = None
_console_directory = "console-recordings"
@classmethod
def get_instance(cls) -> AgentsConsole:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self) -> None:
theme: dict[str, str | Style] = {
"tag": "black on #1fd5f9",
"label": "#8f83ff",
"error": "red",
"lk-fg": "#1fd5f9",
"log.name": Style.null(),
"log.extra": Style(dim=True),
"logging.level.notset": Style(dim=True),
"logging.level.debug": Style(color="cyan"),
"logging.level.info": Style(color="green"),
"logging.level.warning": Style(color="yellow"),
"logging.level.dev": Style(color="blue"),
"logging.level.error": Style(color="red", bold=True),
"logging.level.critical": Style(color="red", bold=True, reverse=True),
}
self.tag_width = 11
self.console = Console(theme=Theme(theme))
self._apm = rtc.AudioProcessingModule(
echo_cancellation=True,
noise_suppression=True,
high_pass_filter=True,
auto_gain_control=True,
)
self._input_delay = 0.0
self._input_name: str | None = None
self._input_stream: sd.InputStream | None = None
self._output_delay = 0.0
self._output_name: str | None = None
self._output_stream: sd.OutputStream | None = None
self._input_lock = threading.Lock()
self._input_levels = np.zeros(14, dtype=np.float32)
self._console_mode: ConsoleMode = "audio"
self._lock = threading.Lock()
self._io_acquired = False
self._io_acquired_event = threading.Event()
self._enabled = False
self._record = False
self._last_metrics_text: Text | None = None
self._last_user_metrics: llm.MetricsReport | None = None
self._text_mode_log_filter = TextModeLogFilter()
self._log_handler = RichLoggingHandler(self)
self._session_directory = pathlib.Path(
self._console_directory,
f"session-{datetime.datetime.now().strftime('%m-%d-%H%M%S')}",
)
# Present so this (local) console satisfies the same interface as the
# TCP console in ``cli.py`` that ``AgentSession``/``JobContext`` consult.
# Always None here: the local console never uses a TCP transport.
self._tcp_transport: Any = None
self._tcp_audio_input: Any = None
self._tcp_audio_output: Any = None
def acquire_io(self, *, loop: asyncio.AbstractEventLoop, session: AgentSession) -> None:
with self._lock:
if self._io_acquired:
raise RuntimeError("the ConsoleIO was already acquired by another session")
if asyncio.get_running_loop() != loop:
raise RuntimeError(
"the ConsoleIO must be acquired in the same asyncio loop as the session"
)
self._io_acquired = True
self._io_loop = loop
self._io_context = contextvars.copy_context()
self._io_audio_input = ConsoleAudioInput(loop)
self._io_audio_output = ConsoleAudioOutput(loop)
self._io_transcription_sync = TranscriptSynchronizer(
next_in_chain_audio=self._io_audio_output,
next_in_chain_text=None,
)
self._io_acquired_event.set()
self._io_session = session
if session:
from ..voice.events import (
AgentStateChangedEvent,
ConversationItemAddedEvent,
)
@session.on("conversation_item_added")
def _on_conversation_item_added(event: ConversationItemAddedEvent) -> None:
if not isinstance(event.item, llm.ChatMessage):
return
if event.item.role == "user":
self._last_user_metrics = event.item.metrics
elif event.item.role == "assistant":
self._last_metrics_text = _format_turn_metrics(
self._last_user_metrics, event.item.metrics
)
self._last_user_metrics = None
@session.on("agent_state_changed")
def _on_agent_state_changed(event: AgentStateChangedEvent) -> None:
if event.new_state == "speaking":
early = session._early_assistant_metrics
if early:
self._last_metrics_text = _format_turn_metrics(
self._last_user_metrics, early
)
session._early_assistant_metrics = None
elif event.new_state == "thinking":
self._last_metrics_text = None
self._update_sess_io(
session,
self.console_mode,
self._io_audio_input,
self._io_transcription_sync.audio_output,
self._io_transcription_sync.text_output,
)
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, val: bool) -> None:
self._enabled = val
@property
def record(self) -> bool:
return self._record
@record.setter
def record(self, val: bool) -> None:
self._record = val
@property
def session_directory(self) -> pathlib.Path:
return self._session_directory
@property
def io_acquired(self) -> bool:
with self._lock:
return self._io_acquired
@property
def io_session(self) -> AgentSession:
if not self._io_acquired:
raise RuntimeError("AgentsConsole is not acquired")
return self._io_session
@property
def io_loop(self) -> asyncio.AbstractEventLoop:
if not self._io_acquired:
raise RuntimeError("AgentsConsole is not acquired")
return self._io_loop
@property
def io_context(self) -> contextvars.Context:
if not self._io_acquired:
raise RuntimeError("AgentsConsole is not acquired")
return self._io_context
def wait_for_io_acquisition(self) -> None:
self._io_acquired_event.wait()
@property
def input_name(self) -> str | None:
return self._input_name
@property
def output_name(self) -> str | None:
return self._output_name
@property
def console_mode(self) -> ConsoleMode:
return self._console_mode
@console_mode.setter
def console_mode(self, mode: ConsoleMode) -> None:
with self._lock:
self._console_mode = mode
if not self._io_acquired:
return
self.io_loop.call_soon_threadsafe(
self._update_sess_io,
self.io_session,
mode,
self._io_audio_input,
self._io_transcription_sync.audio_output,
self._io_transcription_sync.text_output,
)
def _update_sess_io(
self,
sess: AgentSession,
mode: ConsoleMode,
audio_input: ConsoleAudioInput,
audio_output: io.AudioOutput,
text_output: io.TextOutput,
) -> None:
if asyncio.get_running_loop() != self.io_loop:
raise RuntimeError("_update_sess_io must be executed on the io_loop")
with self._lock:
if not self._io_acquired:
return
if self._io_session != sess or self._console_mode != mode:
return
if mode == "text":
sess.input.audio = None
sess.output.audio = None
sess.output.transcription = None
self._log_handler.addFilter(self._text_mode_log_filter)
else:
sess.input.audio = audio_input
sess.output.audio = audio_output
sess.output.transcription = text_output
self._log_handler.removeFilter(self._text_mode_log_filter)
def print(
self, child: RenderableType, *, tag: str = "", tag_style: Style | None = None
) -> None:
self.console.print(self._render_tag(child, tag=tag, tag_style=tag_style))
def _render_tag(
self,
child: RenderableType,
*,
tag: str = "",
tag_width: int | None = None,
tag_style: Style | None = None,
) -> ConsoleRenderable:
if tag:
tag = f" {tag} "
tag_width = tag_width or self.tag_width
table = Table.grid(
Column(width=tag_width + 2, no_wrap=True),
Column(no_wrap=False, overflow="fold"),
padding=(0, 0, 0, 0),
collapse_padding=True,
pad_edge=False,
)
left_padding = tag_width - len(tag)
left_padding = max(0, left_padding)
style = tag_style or self.console.get_style("tag")
tag_segments = [Segment(tag, style=style)]
left = [Segment(" " * left_padding), *tag_segments]
table.add_row(Group(*left), Group(child)) # type: ignore
return table
def set_microphone_enabled(self, enable: bool, *, device: int | str | None = None) -> None:
if self._input_stream:
self._input_stream.close()
self._input_stream = self._input_name = None
if not enable:
return
import sounddevice as sd
if device is None:
device, _ = sd.default.device
try:
device_info = sd.query_devices(device, kind="input")
except Exception:
raise CLIError(
"Unable to access the microphone. \n"
"Please ensure a microphone is connected and recognized by your system. "
"To see available input devices, run: lk-agents console --list-devices"
) from None
assert isinstance(device_info, dict), "device_info is dict"
self._input_name = device_info.get("name", "Unnamed microphone")
self._input_stream = sd.InputStream(
callback=self._sd_input_callback,
dtype="int16",
channels=1,
device=device,
samplerate=24000,
blocksize=2400,
)
self._input_stream.start()
def set_speaker_enabled(self, enable: bool, *, device: int | str | None = None) -> None:
if self._output_stream:
self._output_stream.close()
self._output_stream = self._output_name = None
if not enable:
return
import sounddevice as sd
if device is None:
_, device = sd.default.device
try:
device_info = sd.query_devices(device, kind="output")
except Exception:
raise CLIError(
"Unable to access the speaker. \n"
"Please ensure a speaker is connected and recognized by your system. "
"To see available output devices, run: lk-agents console --list-devices"
) from None
assert isinstance(device_info, dict), "device_info is dict"
self._output_name = device_info.get("name", "Unnamed speaker")
self._output_stream = sd.OutputStream(
callback=self._sd_output_callback,
dtype="int16",
channels=1,
device=device,
samplerate=24000,
blocksize=2400,
)
self._output_stream.start()
def _validate_device_or_raise(
self, *, input_device: str | None, output_device: str | None
) -> None:
import sounddevice as sd
try:
if input_device:
sd.query_devices(input_device, kind="input")
except Exception:
raise CLIError(
"Unable to access the microphone. \n"
"Please ensure a microphone is connected and recognized by your system. "
"To see available input devices, run: lk-agents console --list-devices"
) from None
try:
if output_device:
sd.query_devices(output_device, kind="output")
except Exception:
raise CLIError(
"Unable to access the speaker. \n"
"Please ensure a speaker is connected and recognized by your system. "
"To see available output devices, run: lk-agents console --list-devices"
) from None
def _sd_input_callback(self, indata: np.ndarray, frame_count: int, time: Any, *_: Any) -> None:
self._input_delay = time.currentTime - time.inputBufferAdcTime
total_delay = self._output_delay + self._input_delay
try:
self._apm.set_stream_delay_ms(int(total_delay * 1000))
except RuntimeError:
pass # setting stream delay in console mode fails often, so we silently continue
sr = 24000
x = indata[:, 0].astype(np.float32) / 32768.0
n = x.size
x *= np.hanning(n).astype(np.float32)
X = np.fft.rfft(x, n=n)
mag = np.abs(X).astype(np.float32) * (2.0 / n)
mag[0] *= 0.5
mag[-1] *= 1.0 - 0.5 * float(n % 2 == 0)
freqs = np.fft.rfftfreq(n, d=1.0 / sr)
nb = len(self._input_levels)
edges = np.geomspace(20.0, (sr * 0.5) * 0.96, nb + 1).astype(np.float32)
b = np.clip(np.digitize(freqs, edges) - 1, 0, nb - 1)
p = (mag * mag).astype(np.float32)
sump = np.bincount(b, weights=p, minlength=nb)
cnts = np.maximum(np.bincount(b, minlength=nb), 1)
pmean = sump / cnts
db = 10.0 * np.log10(pmean + 1e-12)
floor_db, hot_db = -70.0, -20
lev = np.clip(((db - floor_db) / (hot_db - floor_db)).astype(np.float32), 0.0, 1.0)
lev = np.maximum(lev**0.75 - 0.02, 0.0)
peak = float(lev.max())
lev *= np.clip(0.95 / (peak + 1e-6), 0.0, 3.0)
lev = np.clip(lev, 0.0, 1.0)
decay = float(np.exp(-(n / sr) / 0.1))
with self._input_lock:
prev = self._input_levels.astype(np.float32)
self._input_levels = np.maximum(lev, prev * decay)
if not self._io_acquired:
return
FRAME_SAMPLES = 240 # 10ms at 24000 Hz
num_frames = frame_count // FRAME_SAMPLES
for i in range(num_frames):
start = i * FRAME_SAMPLES
end = start + FRAME_SAMPLES
capture_chunk = indata[start:end]
frame = rtc.AudioFrame(
data=capture_chunk.tobytes(),
samples_per_channel=FRAME_SAMPLES,
sample_rate=24000,
num_channels=1,
)
self._apm.process_stream(frame)
in_data_aec = np.frombuffer(frame.data, dtype=np.int16)
rms = np.sqrt(np.mean(in_data_aec.astype(np.float32) ** 2))
max_int16 = np.iinfo(np.int16).max
self._micro_db = 20.0 * np.log10(rms / max_int16 + 1e-6)
self._io_loop.call_soon_threadsafe(self._io_audio_input.push_frame, frame)
def _sd_output_callback(self, outdata: np.ndarray, frames: int, time: Any, *_: Any) -> None:
if not self.io_acquired:
outdata[:] = 0
return
self._output_delay = time.outputBufferDacTime - time.currentTime
FRAME_SAMPLES = 240
with self._io_audio_output.audio_lock:
if self._io_audio_output.paused:
outdata[:] = 0
else:
bytes_needed = frames * 2
if len(self._io_audio_output.audio_buffer) < bytes_needed:
available_bytes = len(self._io_audio_output.audio_buffer)
if available_bytes > 0:
self._io_audio_output._maybe_mark_playback_started()
outdata[: available_bytes // 2, 0] = np.frombuffer(
self._io_audio_output.audio_buffer,
dtype=np.int16,
count=available_bytes // 2,
)
outdata[available_bytes // 2 :, 0] = 0
del self._io_audio_output.audio_buffer[:available_bytes] # TODO: optimize
self.io_loop.call_soon_threadsafe(self._io_audio_output.mark_output_empty)
else:
self._io_audio_output._maybe_mark_playback_started()
chunk = self._io_audio_output.audio_buffer[:bytes_needed]
outdata[:, 0] = np.frombuffer(chunk, dtype=np.int16, count=frames)
del self._io_audio_output.audio_buffer[:bytes_needed]
num_chunks = frames // FRAME_SAMPLES
for i in range(num_chunks):
start = i * FRAME_SAMPLES
end = start + FRAME_SAMPLES
render_chunk = outdata[start:end, 0]
render_frame_for_aec = rtc.AudioFrame(
data=render_chunk.tobytes(),
samples_per_channel=FRAME_SAMPLES,
sample_rate=24000,
num_channels=1,
)
self._apm.process_reverse_stream(render_frame_for_aec)
AUDIO_SHORTCUTS = [
("Ctrl+T", "text mode"),
("Ctrl+C", "exit"),
]
class FrequencyVisualizer:
def __init__(self, agents_console: AgentsConsole, *, label: str = "Unlabeled microphone"):
self.label = label
self.height_chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
self.c = agents_console
self.show_shortcuts = False
def update(self) -> None:
with self.c._input_lock:
lv = list(self.c._input_levels)
self._levels_idx = [max(0, min(7, int(round(v * 7)))) for v in lv]
def __rich__(self) -> RenderableType:
table = Table.grid(padding=0, expand=True)
table.add_column()
label = f" {self.label} "
bar = "".join(f" {self.height_chars[i]}" for i in self._levels_idx)
style = self.c.console.get_style("label")
label_seg = Text(label, style=style)
metrics_text = self.c._last_metrics_text
left_width = len(label) + len(bar)
inner_table = Table.grid(
Column(width=len(label), no_wrap=True),
Column(no_wrap=True, overflow="fold"),
padding=(0, 0, 0, 0),
collapse_padding=True,
pad_edge=False,
)
inner_table.add_row(Group(label_seg), Group(bar))
table.add_row(inner_table)
if metrics_text is not None:
metrics_width = len(metrics_text.plain)
console_width = self.c.console.width
if left_width + metrics_width + 4 <= console_width:
# fits on the same row — re-do as 3-column layout
table = Table.grid(padding=0, expand=True)
table.add_column()
wide_table = Table.grid(
Column(width=len(label), no_wrap=True),
Column(no_wrap=True, overflow="fold"),
Column(no_wrap=True, justify="right"),
padding=(0, 0, 0, 0),
collapse_padding=True,
pad_edge=False,
expand=True,
)
wide_table.add_row(Group(label_seg), Group(bar), metrics_text)
table.add_row(wide_table)
else:
# metrics on a separate line, right-aligned
right_metrics = metrics_text.copy()
right_metrics.justify = "right"
table.add_row(right_metrics)
table.add_row(Text(""))
if self.show_shortcuts:
for shortcut_key, desc in AUDIO_SHORTCUTS:
table.add_row(
Text.assemble((" ", ""), (shortcut_key, "dim bold"), (f" {desc}", "dim"))
)
else:
table.add_row(Text(" ? for shortcuts", style="dim"))
return table
class RichLoggingHandler(logging.Handler):
def __init__(self, agents_console: AgentsConsole):
super().__init__()
self.c = agents_console
# used to avoid rendering two same time
self._last_time: Text | None = None
def emit(self, record: logging.LogRecord) -> None:
def middle_truncate(s: str, max_width: int) -> str:
if len(s) <= max_width:
return s
if max_width <= 1:
return "…"[:max_width]
visible = max_width - 1 # leave room for the ellipsis
left = visible // 2
right = visible - left
return s[:left] + "…" + s[-right:]
has_exc = bool(
(record.exc_info and record.exc_info != (None, None, None)) or record.exc_text
)
if has_exc:
exc_info, exc_text = record.exc_info, record.exc_text
record.exc_info = None # temporarily strip for clean message
record.exc_text = None
try:
message = self.format(record)
finally:
record.exc_info, record.exc_text = exc_info, exc_text
else:
message = self.format(record)
MAX_NAME_WIDTH = 18
output = Table.grid(padding=(0, 1))
output.add_column(style="log.time")
output.add_column(style="log.level", width=8, no_wrap=True)
output.add_column(style="log.name", width=MAX_NAME_WIDTH, no_wrap=True, overflow="ellipsis")
output.add_column(ratio=1, style="log.message")
output.add_column(style="log.extra", no_wrap=True)
row: list[RenderableType] = []
time_format = None if self.formatter is None else self.formatter.datefmt
log_time = datetime.datetime.fromtimestamp(record.created)
log_time = log_time or self.c.console.get_datetime()
log_time_display = (
Text(log_time.strftime(time_format))
if time_format
else Text(log_time.strftime("%H:%M:%S.%f")[:-3])
)
if log_time_display == self._last_time:
time_str = log_time_display.plain
row.append(Text(" " * len(time_str)))
else:
row.append(log_time_display)
self._last_time = log_time_display
level_text = Text.styled(
record.levelname.ljust(8),
f"logging.level.{record.levelname.lower()}",
)
row.append(level_text)
logger_name = middle_truncate(record.name, MAX_NAME_WIDTH)
name_text = Text(logger_name)
row.append(name_text)
msg_text = Text(message)
row.append(msg_text)
console_width = self.c.console.width
tag_width = 2 # matches self.c._render_tag(..., tag_width=2)
available_width = max(console_width - tag_width - 6, 20)
time_len = log_time_display.cell_len
level_len = 8
name_len = min(name_text.cell_len, 16)
msg_len = msg_text.cell_len
extra: dict[Any, Any] = {}
_merge_record_extra(record, extra)
extra_str = ""
extra_len = 0
if extra:
extra_str = json.dumps(extra, cls=JsonFormatter.JsonEncoder, ensure_ascii=False)
extra_text = Text(extra_str)
extra_len = extra_text.cell_len
spaces_between_columns = 4
total_len_with_extra = (
time_len + level_len + name_len + msg_len + extra_len + spaces_between_columns
)
inline_extra = bool(extra_str) and total_len_with_extra <= available_width
if inline_extra:
row.append(Text(extra_str, style="log.extra"))
else:
row.append(Text(" "))
output.add_row(*row)
output = self.c._render_tag(output, tag_width=tag_width) # type: ignore
try:
self.c.console.print(output)
if extra_str and not inline_extra:
indent_width = tag_width + time_len + 1 + level_len + 1 + name_len + 1
indent = " " * (indent_width + 2)
extra_line = Text(indent + extra_str, style="log.extra")
self.c.console.print(extra_line)
if has_exc:
self._print_plain_traceback(record)
except Exception:
self.handleError(record)
def _print_plain_traceback(self, record: logging.LogRecord) -> None:
try:
if record.exc_text:
tb_str = record.exc_text
else:
exc_type, exc_value, exc_tb = record.exc_info # type: ignore[misc]
tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
tb_text = Text(tb_str, style="red")
self.c.console.print(tb_text, end="")
self.c.console.print()
except Exception:
self.handleError(record)
def _configure_logger(c: AgentsConsole | None, log_level: int | str) -> None:
logging.addLevelName(TRACE_LOG_LEVEL, "TRACE")
root = logging.getLogger()
if c:
root.addHandler(c._log_handler)
else:
handler = logging.StreamHandler(sys.stdout)
root.addHandler(handler)
handler.setFormatter(JsonFormatter())
root.setLevel(log_level)
_silence_noisy_loggers()
from ..log import logger
if logger.level == logging.NOTSET:
logger.setLevel(log_level)
from ..plugin import Plugin
def _configure_plugin_logger(plugin: Plugin) -> None:
if plugin.logger is not None and plugin.logger.level == logging.NOTSET:
plugin.logger.setLevel(log_level)
for plugin in Plugin.registered_plugins:
_configure_plugin_logger(plugin)
Plugin.emitter.on("plugin_registered", _configure_plugin_logger)
class TextModeLogFilter(logging.Filter):
# We don't want to remove the DEBUG logs from the agents codebase since they're useful. But we now have duplicate content when using
# the text mode, so we use logging.Filter
_patterns = [
re.compile(r"\bexecuting tool\b", re.IGNORECASE),
re.compile(r"\btools execution completed\b", re.IGNORECASE),
]
def filter(self, record: logging.LogRecord) -> bool:
if record.name != "livekit.agents":
return True
msg = record.getMessage()
return not any(rx.search(msg) for rx in self._patterns)
def _print_audio_devices() -> None:
import sounddevice as sd
console = Console()
devices = sd.query_devices()
default_input, default_output = sd.default.device
table = Table(show_header=True, show_lines=False, box=None)
table.add_column("ID", style="#1fd5f9", justify="right")
table.add_column("Type", style="bold", justify="center")
table.add_column("Name", style="bold")
table.add_column("Default", justify="center")
for idx, dev in enumerate(devices):
name = dev["name"]
has_input = dev["max_input_channels"] > 0
has_output = dev["max_output_channels"] > 0
if has_input:
default = Text("yes", style="#23de6b") if idx == default_input else ""
table.add_row(str(idx), Text("Input", style="#6c7a89"), name, default)
if has_output:
default = Text("yes", style="#23de6b") if idx == default_output else ""
table.add_row(str(idx), Text("Output", style="#6c7a89"), name, default)
console.print(table)
TEXT_SHORTCUTS = [
("Ctrl+T", "audio mode"),
("Ctrl+C", "exit"),
]
def prompt(
message: str | Text,
*,
console: Console,
key_read_cb: Callable[[str], Any] | None = None,
placeholder: str = "",
) -> str:
buffer: list[str] = []
width = console.size.width
line_char = "\u2500"
show_shortcuts = False
def render_prompt() -> Table:
table = Table.grid(padding=0)
table.add_column()
table.add_row(Text(line_char * width, style="dim"))
input_text = "".join(buffer)
if input_text:
table.add_row(Text.assemble(("\u276f ", "bold"), (input_text, ""), ("\u2588", "white")))
else:
table.add_row(
Text.assemble(
("\u276f ", "bold"), ("\u2588", "white"), (" ", ""), (placeholder, "dim italic")
)
)
table.add_row(Text(line_char * width, style="dim"))
if show_shortcuts:
for shortcut_key, desc in TEXT_SHORTCUTS:
table.add_row(
Text.assemble((" ", ""), (shortcut_key, "dim bold"), (f" {desc}", "dim"))
)
elif not buffer:
table.add_row(Text(" ? for shortcuts", style="dim"))
return table
with Live(render_prompt(), console=console, transient=True, refresh_per_second=30) as live:
while True:
ch = readkey()
if key_read_cb:
key_read_cb(ch)
if ch == key.ENTER:
break
# Toggle shortcuts menu with ? (only when buffer is empty) or close with Escape
if ch == "?" and not buffer:
show_shortcuts = not show_shortcuts
live.update(render_prompt())
continue
if ch == key.ESC:
if show_shortcuts:
show_shortcuts = False
live.update(render_prompt())
continue
if ch == key.BACKSPACE:
if buffer:
buffer.pop()
live.update(render_prompt())
continue
if len(ch) == 1 and ch.isprintable():
buffer.append(ch)
live.update(render_prompt())
return "".join(buffer)
UpdateFn = Callable[[str | Text | None], None]
@contextmanager
def live_status(
console: Console,
text: str | Text,
*,
spinner: str = "line",
spinner_style: str = "bold blue",
refresh_per_second: int = 12,
transient: bool = True,
) -> Iterator[UpdateFn]:
msg: Text = text if isinstance(text, Text) else Text(str(text))
spin = Spinner(spinner, style=spinner_style)
def _render() -> Columns:
return Columns([msg, spin], expand=False, equal=False, padding=(0, 1))
with Live(
_render(),
console=console,
refresh_per_second=refresh_per_second,
transient=transient,
) as live:
def update(new_text: str | Text | None = None) -> None:
nonlocal msg
if new_text is not None:
msg = new_text if isinstance(new_text, Text) else Text(str(new_text))
live.update(_render())
yield update
def _text_mode(c: AgentsConsole) -> None:
def _key_read(ch: str) -> None:
if ch == key.CTRL_T:
raise _ToggleMode()
while True:
try:
text = prompt(
Text.from_markup(" [bold]User input[/bold]: "),
console=c.console,
key_read_cb=_key_read,
placeholder="Type to talk to your agent",
)
except KeyboardInterrupt:
break
if not text.strip():
c.console.bell()
continue
def _generate_with_context(text: str, result_fut: asyncio.Future[list[RunEvent]]) -> None:
async def _generate(text: str) -> list[RunEvent]:
sess = await c.io_session.run(user_input=text) # type: ignore
return sess.events.copy()
def _done_callback(task: asyncio.Task[list[RunEvent]]) -> None:
if exception := task.exception():
result_fut.set_exception(exception)
else:
result_fut.set_result(task.result())
task = asyncio.create_task(_generate(text))
task.add_done_callback(_done_callback)
h: asyncio.Future[list[RunEvent]] = c.io_loop.create_future()
c.io_loop.call_soon_threadsafe(_generate_with_context, text, h, context=c.io_context)
c.console.print()
c.console.print(
Text.assemble(
(" \u25cf ", "#1FD5F9"),
("You", "bold #1FD5F9"),
)
)
for line in text.split("\n"):
c.console.print(Text(f" {line}"))
with live_status(c.console, Text.from_markup(" [dim]Thinking...[/dim]")):
while not h.done():
time.sleep(0.1)
last_user_metrics: llm.MetricsReport | None = None
for event in h.result():
if event.type == "message" and event.item.role == "user":
last_user_metrics = event.item.metrics
_print_run_event(c, event, last_user_metrics)
AGENT_PALETTE: list[str] = [
"#1FD5F9",
"#09C338",
"#1F5DF9",
"#BA1FF9",
"#F9AE1F",
"#FA4C39",
]
def _agent_style(name: str) -> Style:
h = hashlib.blake2b(name.encode("utf-8"), digest_size=2).digest()
idx = int.from_bytes(h, "big") % len(AGENT_PALETTE)
return Style(color=AGENT_PALETTE[idx], bold=True)
def _truncate_text(text: str, max_lines: int = 2, width: int = 80) -> str:
wrapped = textwrap.wrap(text, width=width)
if len(wrapped) <= max_lines:
return "\n".join(wrapped)
head_count = max_lines - 2
head = wrapped[:head_count]
tail = wrapped[-1:]
return "\n".join(head + ["..."] + tail)
def _format_duration_ms(seconds: float) -> str:
ms = seconds * 1000
if ms >= 10:
return f"{ms:.0f}ms"
elif ms >= 1:
return f"{ms:.1f}ms"
else:
return f"{ms:.2f}ms"
def _format_turn_metrics(
user_metrics: llm.MetricsReport | None,
assistant_metrics: llm.MetricsReport | None,
) -> Text | None:
parts: list[tuple[str, str]] = []
# user-side metrics
if user_metrics:
if "end_of_turn_delay" in user_metrics:
v = user_metrics["end_of_turn_delay"]
parts.append(("end_of_turn: ", "dim"))
parts.append((_format_duration_ms(v), "dim"))
if "on_user_turn_completed_delay" in user_metrics:
v = user_metrics["on_user_turn_completed_delay"]
parts.append(("turn_completed_cb: ", "dim"))
parts.append((_format_duration_ms(v), "dim"))
# assistant-side metrics
if assistant_metrics:
if "llm_node_ttft" in assistant_metrics:
v = assistant_metrics["llm_node_ttft"]
parts.append(("llm_ttft: ", "dim"))
parts.append((_format_duration_ms(v), "dim"))
if "tts_node_ttfb" in assistant_metrics:
v = assistant_metrics["tts_node_ttfb"]
parts.append(("tts_ttfb: ", "dim"))
parts.append((_format_duration_ms(v), "dim"))
e2e_parts: list[tuple[str, str]] = []
if assistant_metrics and "e2e_latency" in assistant_metrics:
v = assistant_metrics["e2e_latency"]
e2e_parts.append(("e2e: ", "dim"))
e2e_parts.append((_format_duration_ms(v), "red" if v >= 1.0 else "dim"))
if not parts and not e2e_parts:
return None
assembled: list[tuple[str, str]] = []
pair_count = len(parts) // 2
for i in range(pair_count):
if i > 0:
assembled.append((" \u00b7 ", "dim"))
assembled.append(parts[i * 2]) # label
assembled.append(parts[i * 2 + 1]) # value
if e2e_parts:
if assembled:
assembled.append((" \u2500 ", "dim"))
assembled.extend(e2e_parts)
return Text.assemble(*assembled)
def _print_run_event(
c: AgentsConsole,
event: RunEvent,
last_user_metrics: llm.MetricsReport | None = None,
) -> None:
if event.type == "function_call":
c.console.print()
c.console.print(
Text.assemble(
(" \u279c ", "#1FD5F9"),
(event.item.name, "bold #1FD5F9"),
)
)
elif event.type == "function_call_output":
output = event.item.output
display_output = output
is_error = output.lower().startswith("error") or output.lower().startswith("exception")
if not is_error:
try:
import json
json_start = output.find("{")
if json_start >= 0:
json_str = output[json_start:]
data = json.loads(json_str)
if isinstance(data, dict):
summary_parts = []
for k, v in data.items():
if v is not None and k != "type":
summary_parts.append(f"{k}={v}")
display_output = ", ".join(summary_parts[:3])
if len(summary_parts) > 3:
display_output += ", ..."
except (json.JSONDecodeError, TypeError, ValueError):
display_output = _truncate_text(output, max_lines=2)
if is_error:
c.console.print(
Text.assemble(
(" \u2717 ", "#EF4444"),
(_truncate_text(output, max_lines=2), "#EF4444"),
)
)
else:
c.console.print(
Text.assemble(
(" \u2713 ", "#6BCB77"),
(display_output, "dim"),
)
)
elif event.type == "agent_handoff":
old_agent = event.old_agent
new_agent = event.new_agent
old_style = _agent_style(old_agent.__class__.__name__)
new_style = _agent_style(new_agent.__class__.__name__)
c.console.print(
Text.assemble(
(" \u25cf ", "#FFD93D"),
("Handoff: ", "bold #FFD93D"),
Text(f"{old_agent.__class__.__name__}", style=old_style),
(" \u2192 ", "dim"),
Text(f"{new_agent.__class__.__name__}", style=new_style),
)
)
elif event.type == "message":
if event.item.text_content:
c.console.print()
c.console.print(
Text.assemble(
(" \u25cf ", "#6BCB77"),
("Agent", "bold #6BCB77"),
)
)
for line in event.item.text_content.split("\n"):
c.console.print(Text(f" {line}"))
metrics_text = _format_turn_metrics(
last_user_metrics if event.item.role == "assistant" else None,
event.item.metrics if event.item.role == "assistant" else None,
)
if metrics_text is not None:
metrics_line = Text(" ")
metrics_line.append_text(metrics_text)
c.console.print(metrics_line)
else:
logger.warning(f"unknown RunEvent type {event.type}")
def _audio_mode(c: AgentsConsole, *, input_device: str | None, output_device: str | None) -> None:
ctrl_t_e = threading.Event()
visualizer: FrequencyVisualizer | None = None
def _listen_for_keys() -> None:
while not ctrl_t_e.is_set():
ch = readkey()
if ch == key.CTRL_T:
ctrl_t_e.set()
break
elif ch == "?" and visualizer is not None:
visualizer.show_shortcuts = not visualizer.show_shortcuts
elif ch == key.ESC and visualizer is not None:
visualizer.show_shortcuts = False
listener = threading.Thread(target=_listen_for_keys, daemon=True)
listener.start()
c.set_microphone_enabled(True, device=input_device)
c.set_speaker_enabled(True, device=output_device)
visualizer = FrequencyVisualizer(c, label=c.input_name or "unknown")
visualizer.update()
with Live(visualizer, console=c.console, refresh_per_second=12, transient=True):
while not ctrl_t_e.is_set():
visualizer.update()
time.sleep(0.05)
c.set_microphone_enabled(False)
c.set_speaker_enabled(False)
if ctrl_t_e.is_set():
raise _ToggleMode()
class _ConsoleWorker:
def __init__(self, *, server: AgentServer, shutdown_cb: Callable) -> None:
self._loop = asyncio.new_event_loop()
self._server = server
self._shutdown_cb = shutdown_cb
self._lock = threading.Lock()
self._closed = False
def start(self) -> None:
self._thread = threading.Thread(target=self._worker_thread)
self._thread.start()
def join(self) -> None:
self._thread.join()
def shutdown(self) -> None:
with self._lock:
asyncio.run_coroutine_threadsafe(self._server.aclose(), self._loop)
def _worker_thread(self) -> None:
asyncio.set_event_loop(self._loop)
async def _async_main() -> None:
with self._lock:
if self._closed:
self._shutdown_cb()
return
self._server._job_executor_type = JobExecutorType.THREAD # TODO: better setter
@self._server.once("worker_started")
def _simulate_job() -> None:
asyncio.run_coroutine_threadsafe(
self._server.simulate_job(
"console-room", agent_identity="console", fake_job=True
),
self._loop,
)
await self._server.run(devmode=True, unregistered=True)
self._shutdown_cb()
self._loop.run_until_complete(_async_main())
def _run_console(
*,
server: AgentServer,
input_device: str | None,
output_device: str | None,
mode: ConsoleMode,
record: bool,
log_level: int | str = logging.DEBUG,
) -> None:
c = AgentsConsole.get_instance()
# Register this rich console as the canonical instance that AgentSession and
# JobContext consult (they import ``AgentsConsole`` from the thin ``cli``
# module). Without this, the local console's audio I/O would never be wired.
from .cli import AgentsConsole as _CanonicalConsole
_CanonicalConsole._instance = c # type: ignore[assignment]
c.console_mode = mode
c.enabled = True
c.record = record
_configure_logger(c, log_level)
c.print("Starting console mode 🚀", tag="Agents")
if c.record:
c.print(
f"Session recording will be saved to {c.session_directory}",
tag="Recording",
tag_style=Style.parse("black on red"),
)
c.print(" ")
# c.print(
# "Searching for package file structure from directories with [blue]__init__.py[/blue] files"
# )
try:
# import_data = get_import_data(path=path)
# c.print(f"Importing from {import_data.module_data.extra_sys_path}")
# c.print(" ")
c._validate_device_or_raise(input_device=input_device, output_device=output_device)
exit_triggered = False
def _on_worker_shutdown() -> None:
try:
signal.raise_signal(signal.SIGTERM)
except Exception:
try:
signal.raise_signal(signal.SIGINT)
except Exception:
pass
def _handle_exit(sig: int, frame: FrameType | None) -> None:
nonlocal exit_triggered
if not exit_triggered:
exit_triggered = True
raise _ExitCli()
console_worker.shutdown()
for sig in HANDLED_SIGNALS:
signal.signal(sig, _handle_exit)
console_worker = _ConsoleWorker(server=server, shutdown_cb=_on_worker_shutdown)
console_worker.start()
# TODO: wait for a session request the agents console context before showing any of the mode
try:
c.wait_for_io_acquisition()
while True:
try:
if c.console_mode == "text":
_text_mode(c)
elif c.console_mode == "audio":
_audio_mode(c, input_device=input_device, output_device=output_device)
except _ToggleMode:
c.console_mode = "audio" if c.console_mode == "text" else "text"
except _ExitCli:
pass
finally:
console_worker.shutdown()
console_worker.join()
except (CLIError, ValueError) as e:
c.print(" ")
c.print(f"[error]{e}")
c.print(" ")
raise typer.Exit(code=1) from None
class LogLevel(str, enum.Enum):
trace = "TRACE"
debug = "DEBUG"
info = "INFO"
warn = "WARN"
error = "ERROR"
critical = "CRITICAL"
def _build_cli(server: AgentServer) -> typer.Typer:
app = typer.Typer(rich_markup_mode="rich")
@app.callback(invoke_without_command=True)
def _set_dev_mode(ctx: typer.Context) -> None:
if ctx.invoked_subcommand is None:
print(ctx.get_help())
raise typer.Exit()
if ctx.invoked_subcommand in ("console", "dev"):
os.environ["LIVEKIT_DEV_MODE"] = "1"
_start_log_default = LogLevel(ServerEnvOption.getvalue(server.log_level, False))
_dev_log_default = LogLevel(ServerEnvOption.getvalue(server.log_level, True))
@app.command()
def console(
*,
input_device: Annotated[
str | None, # noqa: UP007, required for python 3.9
typer.Option(
help="Numeric input device ID or input device name substring(s)",
),
] = None,
output_device: Annotated[
str | None, # noqa: UP007
typer.Option(
help="Numeric output device ID or output device name substring(s)",
),
] = None,
list_devices: Annotated[
bool,
typer.Option(
help="List all available input and output audio devices.",
),
] = False,
text: Annotated[
bool,
typer.Option(help="Whether to start the console in text mode"),
] = False,
record: Annotated[bool, typer.Option(help="Whether to record the AgentSession")] = False,
log_level: Annotated[
LogLevel,
typer.Option(
help="Set the log level", case_sensitive=False, envvar="LIVEKIT_LOG_LEVEL"
),
] = _dev_log_default,
) -> None:
"""
Run a [bold]LiveKit Agents[/bold] in [yellow]console[/yellow] mode.
"""
if list_devices:
_print_audio_devices()
raise typer.Exit()
if input_device and input_device.isdigit():
input_device = int(input_device) # type: ignore
if output_device and output_device.isdigit():
output_device = int(output_device) # type: ignore
_run_console(
server=server,
input_device=input_device,
output_device=output_device,
mode="text" if text else "audio",
record=record,
log_level=log_level.value,
)
@app.command()
def start(
*,
log_level: Annotated[
LogLevel,
typer.Option(
help="Set the log level", case_sensitive=False, envvar="LIVEKIT_LOG_LEVEL"
),
] = _start_log_default,
url: Annotated[
str | None, # noqa: UP007
typer.Option(
help="The WebSocket URL of your LiveKit server or Cloud project.",
envvar="LIVEKIT_URL",
),
] = None,
api_key: Annotated[
str | None, # noqa: UP007
typer.Option(
help="API key for authenticating with your LiveKit server or Cloud project.",
envvar="LIVEKIT_API_KEY",
),
] = None,
api_secret: Annotated[
str | None, # noqa: UP007
typer.Option(
help="API secret for authenticating with your LiveKit server or Cloud project.",
envvar="LIVEKIT_API_SECRET",
),
] = None,
drain_timeout: Annotated[
int | None, # noqa: UP007
typer.Option(
help="Time in seconds to wait for jobs to finish before shutting down.",
),
] = None,
simulation: Annotated[
bool,
typer.Option(
hidden=True,
help="Run under an agent simulation: the worker load limit is disabled "
"so runs can saturate the agent. Set by `lk simulate`.",
),
] = False,
) -> None:
if drain_timeout is not None:
server.update_options(drain_timeout=drain_timeout)
_cli._run_worker(
server=server,
args=proto.CliArgs(
log_level=log_level.value,
url=url,
api_key=api_key,
api_secret=api_secret,
simulation=simulation,
),
)
@app.command()
def dev(
*,
log_level: Annotated[
LogLevel,
typer.Option(
help="Set the log level", case_sensitive=False, envvar="LIVEKIT_LOG_LEVEL"
),
] = _dev_log_default,
reload: Annotated[
bool,
typer.Option(help="Enable auto-reload of the server when (code) files change."),
] = True,
url: Annotated[
str | None, # noqa: UP007
typer.Option(
help="The WebSocket URL of your LiveKit server or Cloud project.",
envvar="LIVEKIT_URL",
),
] = None,
api_key: Annotated[
str | None, # noqa: UP007
typer.Option(
help="API key for authenticating with your LiveKit server or Cloud project.",
envvar="LIVEKIT_API_KEY",
),
] = None,
api_secret: Annotated[
str | None, # noqa: UP007
typer.Option(
help="API secret for authenticating with your LiveKit server or Cloud project.",
envvar="LIVEKIT_API_SECRET",
),
] = None,
) -> None:
if reload:
logger.warning(
"in-process auto-reload has been removed from the Python CLI; "
"use `lk agent dev` for hot-reload"
)
_cli._run_worker(
server=server,
args=proto.CliArgs(
log_level=log_level.value,
url=url,
api_key=api_key,
api_secret=api_secret,
dev=True,
),
)
@app.command()
def connect(
*,
log_level: Annotated[
LogLevel,
typer.Option(help="Set the log level", case_sensitive=False),
] = LogLevel.debug,
url: Annotated[
str | None, # noqa: UP007
typer.Option(
help="The WebSocket URL of your LiveKit server or Cloud project.",
envvar="LIVEKIT_URL",
),
] = None,
api_key: Annotated[
str | None, # noqa: UP007
typer.Option(
help="API key for authenticating with your LiveKit server or Cloud project.",
envvar="LIVEKIT_API_KEY",
),
] = None,
api_secret: Annotated[
str | None, # noqa: UP007
typer.Option(
help="API secret for authenticating with your LiveKit server or Cloud project.",
envvar="LIVEKIT_API_SECRET",
),
] = None,
room: Annotated[
str,
typer.Option(help="Room name to connect to"),
],
participant_identity: Annotated[
str | None, # noqa: UP007
typer.Option(help="Participant identity"),
] = None,
) -> None:
if participant_identity is None:
participant_identity = shortuuid("agent-")
c = AgentsConsole.get_instance()
_configure_logger(c, log_level.value)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_task: asyncio.Task | None = None
@server.once("worker_started")
def _simulate_job() -> None:
nonlocal _task
async def simulate_job() -> None:
async with api.LiveKitAPI(url, api_key, api_secret) as lk_api:
room_request = api.ListRoomsRequest(names=[room])
active_room = await lk_api.room.list_rooms(room_request)
if not active_room.rooms:
room_info = await lk_api.room.create_room(api.CreateRoomRequest(name=room))
else:
room_info = active_room.rooms[0]
await server.simulate_job(
room=room,
fake_job=False,
room_info=room_info,
agent_identity=participant_identity,
)
_task = asyncio.create_task(simulate_job())
try:
loop.run_until_complete(server.run(devmode=True, unregistered=True))
except _ExitCli:
raise typer.Exit() from None
except KeyboardInterrupt:
logger.warning("exiting forcefully")
os._exit(1)
except (CLIError, ValueError) as e:
c.print(" ")
c.print(f"[error]{e}")
c.print(" ")
raise typer.Exit(code=1) from None
@app.command()
def download_files() -> None:
import warnings
c = AgentsConsole.get_instance()
c.enabled = True
_configure_logger(c, logging.DEBUG)
c.print(
"[yellow]Invoking the download-files command via your agent script is "
"deprecated as of 1.5.10. Run it directly against the livekit.agents module "
"instead, e.g. `uv run -m livekit.agents download-files`.[/yellow]"
)
warnings.warn(
"Invoking the download-files command via your agent script is deprecated "
"as of 1.5.10. Run it directly against the livekit.agents module instead, "
"e.g. `uv run -m livekit.agents download-files`.",
DeprecationWarning,
stacklevel=2,
)
try:
for plugin in Plugin.registered_plugins:
logger.info(f"Downloading files for {plugin.package}")
plugin.download_files()
logger.info(f"Finished downloading files for {plugin.package}")
except CLIError as e:
c.print(" ")
c.print(f"[error]{e}")
c.print(" ")
raise typer.Exit(code=1) from None
return app
def run_app(server: AgentServer | WorkerOptions) -> None:
import warnings
warnings.warn(
"the built-in Python CLI is deprecated; use the LiveKit CLI (`lk agent ...`) "
"or `python -m livekit.agents`. It will be removed in a future release.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(server, WorkerOptions):
server = AgentServer.from_server_options(server)
_build_cli(server)()