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

388 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import contextvars
import os
import signal
import threading
from types import FrameType
from typing import TYPE_CHECKING, Any, Literal
from ..job import JobExecutorType
from ..log import logger
from ..voice import AgentSession, io
from ..voice.transcription import TranscriptSynchronizer
from ..worker import AgentServer, WorkerOptions
from . import proto
from .log import setup_logging
if TYPE_CHECKING:
from ..voice.remote_session import TcpSessionTransport
from .tcp_console import TcpAudioInput, TcpAudioOutput
HANDLED_SIGNALS = (
signal.SIGINT,
signal.SIGTERM,
)
class _ExitCli(BaseException):
pass
ConsoleMode = Literal["text", "audio"]
class AgentsConsole:
"""Minimal console stub for TCP console mode (Go CLI handles the TUI)."""
_instance: AgentsConsole | None = None
@classmethod
def get_instance(cls) -> AgentsConsole:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self) -> None:
import datetime
import pathlib
self._lock = threading.Lock()
self._io_acquired = False
self._io_acquired_event = threading.Event()
self._enabled = False
self._record = False
self._console_mode: ConsoleMode = "audio"
self._tcp_transport: TcpSessionTransport | None = None
self._tcp_audio_input: TcpAudioInput | None = None
self._tcp_audio_output: TcpAudioOutput | None = None
self._session_directory = pathlib.Path(
"console-recordings",
f"session-{datetime.datetime.now().strftime('%m-%d-%H%M%S')}",
)
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()
assert self._tcp_transport is not None
assert self._tcp_audio_input is not None
assert self._tcp_audio_output is not None
self._io_audio_input = self._tcp_audio_input
self._io_audio_output = self._tcp_audio_output
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:
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) -> Any:
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 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: io.AudioInput,
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
else:
sess.input.audio = audio_input
sess.output.audio = audio_output
sess.output.transcription = text_output
def _run_tcp_console(*, server: AgentServer, connect_addr: str, record: bool = False) -> None:
"""Run console in TCP mode — connects to the Go CLI's TCP server."""
from ..voice.remote_session import TcpSessionTransport
from .tcp_console import TcpAudioInput, TcpAudioOutput
host, port_str = connect_addr.rsplit(":", 1)
port = int(port_str)
setup_logging("DEBUG", devmode=True, console=True)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
tcp_audio_input: TcpAudioInput | None = None
async def _tcp_main() -> None:
nonlocal tcp_audio_input
transport = TcpSessionTransport(host, port)
server._job_executor_type = JobExecutorType.THREAD
console_inst = AgentsConsole.get_instance()
console_inst.enabled = True
console_inst.record = record
console_inst._tcp_transport = transport
tcp_audio_input = TcpAudioInput()
console_inst._tcp_audio_input = tcp_audio_input
console_inst._tcp_audio_output = TcpAudioOutput(transport)
@server.once("worker_started")
def _simulate_job() -> None:
asyncio.run_coroutine_threadsafe(
server.simulate_job("console-room", agent_identity="console", fake_job=True),
loop,
)
try:
await server.run(devmode=True, unregistered=True)
finally:
await transport.close()
exit_triggered = False
async def _graceful_shutdown() -> None:
if tcp_audio_input is not None:
tcp_audio_input.close()
await server.aclose()
def _handle_exit(sig: int, frame: FrameType | None) -> None:
nonlocal exit_triggered
if exit_triggered:
os.killpg(os.getpgid(0), signal.SIGKILL)
exit_triggered = True
asyncio.run_coroutine_threadsafe(_graceful_shutdown(), loop)
for sig in HANDLED_SIGNALS:
signal.signal(sig, _handle_exit)
try:
loop.run_until_complete(_tcp_main())
finally:
for sig in HANDLED_SIGNALS:
signal.signal(sig, lambda *_: os._exit(1))
try:
tasks = asyncio.all_tasks(loop)
for task in tasks:
task.cancel()
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
except Exception:
pass
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
except Exception:
pass
loop.close()
def _run_worker(server: AgentServer, args: proto.CliArgs) -> None:
kwargs: dict = {}
if args.url:
kwargs["ws_url"] = args.url
if args.api_key:
kwargs["api_key"] = args.api_key
if args.api_secret:
kwargs["api_secret"] = args.api_secret
if kwargs:
server.update_options(**kwargs)
if args.simulation:
server._simulation = True
if args.cli_addr and not args.dev:
raise ValueError("--cli-addr requires --dev")
devmode = args.dev
colored_logs = devmode or args.log_format == "colored"
exit_raised = False
def _handle_exit(sig: int, frame: FrameType | None) -> None:
nonlocal exit_raised
if exit_raised:
os._exit(1)
exit_raised = True
raise _ExitCli()
for sig in HANDLED_SIGNALS:
signal.signal(sig, _handle_exit)
setup_logging(args.log_level, devmode=colored_logs, console=False, compact=args.simulation)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.slow_callback_duration = 0.1 # 100ms
async def _worker_run(worker: AgentServer) -> None:
try:
await server.run(devmode=devmode, unregistered=False)
except Exception:
logger.exception("worker failed")
watch_client = None
if args.cli_addr:
from .watcher import WatchClient
watch_client = WatchClient(server, args.cli_addr, loop=loop)
watch_client.start()
try:
main_task = loop.create_task(_worker_run(server), name="worker_main_task_cli")
try:
loop.run_until_complete(main_task)
except _ExitCli:
pass
# Second Ctrl+C force-exits.
def _force_exit(sig: int, frame: FrameType | None) -> None:
logger.warning("exiting forcefully", extra={"signal": sig})
os._exit(1)
for sig in HANDLED_SIGNALS:
signal.signal(sig, _force_exit)
try:
if not devmode:
try:
loop.run_until_complete(server.drain())
except asyncio.TimeoutError:
logger.warning("drain timed out, forcing shutdown")
loop.run_until_complete(server.aclose())
if watch_client:
loop.run_until_complete(watch_client.aclose())
except _ExitCli:
pass # stray from first signal — ignore
finally:
# Re-enable force exit for the final cleanup phase
for sig in HANDLED_SIGNALS:
signal.signal(sig, lambda *_: os._exit(1))
try:
tasks = asyncio.all_tasks(loop)
for task in tasks:
task.cancel()
loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
except Exception:
pass
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
except Exception:
pass
loop.close()
def run_app(server: AgentServer | WorkerOptions) -> None:
"""Run the agent via the (deprecated) rich Python CLI.
This is the default entry used by ``python myagent.py <command>``. The rich
CLI lives in ``._legacy`` and is being phased out in favor of the LiveKit CLI
(``lk agent ...``) and the thin interface in ``livekit.agents.__main__``.
"""
from . import _legacy
_legacy.run_app(server)