feat: integrate durable chat voice transport and deployment assets

This commit is contained in:
snowspeeder committed 2026-10-03 09:02:47 -04:00
1 parent e059660194
commit 523f6cbbf9
14 files changed
+487 -14

No files matched your search

+5 -5
View File
@@ -13,9 +13,9 @@ from livekit import rtc
from pathlib import Path
try:
from agent.hermes_llm import HermesLLM
from agent.conversation_llm import ConversationLLM
except ImportError:
from hermes_llm import HermesLLM
from conversation_llm import ConversationLLM
try:
from web.voice_catalog import normalize_default_voice
@@ -124,9 +124,9 @@ async def entrypoint(ctx: JobContext):
raise RuntimeError("Account mismatch")
return current["model_settings"]
bridge = HermesLLM(base_url=account["hermes_url"], api_key=account["hermes_api_key"], user_id=user_id,
room_id=ctx.room.name, on_progress=progress, on_wait_notice=wait_notice,
on_answer_start=stop_notice, get_model_settings=model_settings)
bridge = ConversationLLM(control_url=CONTROL_URL, secret=CONTROL_SECRET,
room_id=ctx.room.name, on_progress=progress,
on_wait_notice=wait_notice, on_answer_start=stop_notice)
tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000,
speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus"))
session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""),
+141
View File
@@ -0,0 +1,141 @@
"""LiveKit speech adapter to the durable Hope coordinator, never a reasoning loop."""
from __future__ import annotations
import asyncio
import json
import uuid
from dataclasses import replace
import httpx
from livekit.agents import llm
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
try:
from agent.hermes_llm import HermesLLMError, _sse
except ImportError:
from hermes_llm import HermesLLMError, _sse
class ConversationLLM(llm.LLM):
def __init__(self, *, control_url, secret, room_id, on_progress, on_wait_notice=None, on_answer_start=None):
super().__init__()
self.root = control_url.rstrip('/') + '/internal/chat/rooms/' + room_id
self.headers = {'X-Control-Secret': secret}
self.client = httpx.AsyncClient(follow_redirects=False, timeout=httpx.Timeout(660, connect=10))
self.on_progress, self.on_wait_notice, self.on_answer_start = on_progress, on_wait_notice, on_answer_start
@property
def model(self): return 'hermes-agent'
@property
def provider(self): return 'hermes'
def chat(self, *, chat_ctx, tools=None, conn_options=DEFAULT_API_CONNECT_OPTIONS, **kwargs):
return ConversationStream(self, chat_ctx, replace(conn_options, max_retry=0))
async def aclose(self):
await super().aclose()
await self.client.aclose()
class ConversationStream(llm.LLMStream):
def __init__(self, bridge, chat_ctx, conn_options):
self.bridge = bridge
self.operation_id = next((m.id for m in reversed(chat_ctx.items) if isinstance(m, llm.ChatMessage) and m.role == 'user' and getattr(m, 'id', None)), uuid.uuid4().hex)
self.run_id = None
self.answer_started = False
self._close_task = None
super().__init__(bridge, chat_ctx=chat_ctx, tools=[], conn_options=conn_options)
async def aclose(self):
# LiveKit can close a stream from interruption and teardown concurrently.
# One underlying closer prevents a second cancellation from aborting
# the first closer's uncertain-admission reconciliation.
if self._close_task is None:
self._close_task = asyncio.create_task(super().aclose())
await asyncio.shield(self._close_task)
async def callback(self, callback, *args):
if callback:
try: await asyncio.wait_for(callback(*args), 5)
except Exception: pass # feedback failure never repeats a tool action
async def progress(self, status, message):
await self.callback(self.bridge.on_progress, {'type': 'assistant_progress', 'request_id': self.run_id or self.operation_id, 'status': status, 'message': message})
async def _run(self):
user = next((m for m in reversed(self.chat_ctx.items) if isinstance(m, llm.ChatMessage) and m.role == 'user'), None)
text = '\n'.join(p for p in user.content if isinstance(p, str)) if user else ''
if not text.strip(): raise HermesLLMError('empty_voice_turn')
timer = None
admission = None
try:
# Do not abandon uncertain admission on barge-in. A successfully
# admitted remote action must be identified and cancelled, not left
# running just because the speech request lost its response.
admission = asyncio.create_task(self.bridge.client.post(self.bridge.root + '/messages', headers=self.bridge.headers,
json={'content': text, 'idempotency_key': self.operation_id, 'modality': 'voice'}, timeout=httpx.Timeout(20, connect=5)))
response = await asyncio.shield(admission)
if response.status_code != 202: raise HermesLLMError('voice_admission_failed')
self.run_id = response.json()['run']['id']
await self.progress('working', 'Working on your request.')
timer = asyncio.create_task(self.notice())
# Voice streams only completed public content. This avoids reading
# a draft that the backend subsequently replaces or rejects.
final = None
async with self.bridge.client.stream('GET', self.bridge.root + '/runs/' + self.run_id + '/events', headers=self.bridge.headers) as stream:
if stream.status_code != 200: raise HermesLLMError('voice_stream_failed')
async for event, raw in _sse(stream):
item = json.loads(raw)
if event == 'progress': await self.progress('working', 'Working on your request.')
if event == 'message':
message = item.get('message', item)
if message.get('role') == 'assistant' and message.get('status') == 'completed': final = message.get('content', '')
if event in ('run', 'done'):
run = item.get('run', item)
if run.get('status') in ('failed', 'cancelled'): raise HermesLLMError('voice_run_' + run['status'])
if run.get('status') == 'awaiting_approval': await self.progress('working', 'Approval needed. Open this conversation to review it.')
if event == 'done': break
if final:
self.answer_started = True
await self.callback(self.bridge.on_answer_start)
self._event_ch.send_nowait(llm.ChatChunk(id=self.operation_id, delta=llm.ChoiceDelta(role='assistant', content=final)))
else: raise HermesLLMError('empty_voice_answer')
await self.progress('completed', 'Completed.')
except asyncio.CancelledError:
# Persist cancellation by operation identity even when admission's
# response was lost. The coordinator tombstones the key, so a later
# identical admission cannot start an uncancelled remote action.
try:
await asyncio.shield(self.bridge.client.post(self.bridge.root + '/operations/' + self.operation_id + '/cancel',
headers=self.bridge.headers, timeout=httpx.Timeout(10, connect=5)))
except Exception: pass
if not self.run_id and admission:
try:
admitted = await asyncio.shield(admission)
if admitted.status_code == 202:
self.run_id = admitted.json()['run']['id']
except Exception:
# A transport failure can be an uncertain acceptance. Retry
# only the identical stable key; the coordinator deduplicates
# admission, then return the run identity for cancellation.
try:
admitted = await self.bridge.client.post(self.bridge.root + '/messages', headers=self.bridge.headers,
json={'content': text, 'idempotency_key': self.operation_id, 'modality': 'voice'})
if admitted.status_code == 202:
self.run_id = admitted.json()['run']['id']
except Exception: pass
if self.run_id:
try:
await asyncio.shield(self.bridge.client.post(self.bridge.root + '/runs/' + self.run_id + '/cancel', headers=self.bridge.headers))
except Exception: pass
await self.progress('failed', 'Request cancelled.')
raise
except Exception:
await self.progress('failed', 'Hope could not complete this response.')
raise HermesLLMError('voice_coordinator_failed') from None
finally:
if timer:
timer.cancel()
await asyncio.gather(timer, return_exceptions=True)
async def notice(self):
await asyncio.sleep(12)
if not self.answer_started:
await self.callback(self.bridge.on_wait_notice, "I'm still working on that. I'll let you know when it's ready.")