Files
hermes-agent/tools/voice_live.py
Teknium f923faa0b8 feat(voice): GPT-Live voice chat mode — a full-duplex voice frontend that delegates to Hermes (Desktop)
`voice.voice_chat_mode: gpt-live` swaps the desktop's chained STT → turn → TTS
loop for OpenAI's gpt-live-1: one voice model that listens while it speaks and
has no tools of its own. Every real request it hears becomes a normal Hermes
turn on the open chat — any model/provider the session selected, full toolset,
memory, approvals — and the voice paraphrases the reply aloud.

Backend
- tools/voice_live.py: mode/credential/persona resolution and the one server-side
  step the API needs — POST /v1/live/sessions exchanging the renderer's SDP offer,
  pinned to client delegation; the OpenAI key never reaches the renderer. Voice
  persona follows the vendor prompting guide (role, style, labelled delegation
  policy describing Hermes as the backend). VOICE_LIVE_TURN_NOTE is the per-turn
  model-input note (transcript in, speakable prose out).
- REST: GET /api/audio/voice-live/status (mode + readiness, non-secret),
  POST /api/audio/voice-live/session (SDP exchange). The offer is passed
  byte-exact: a stripped trailing CRLF is a vendor 400 "unmarshal SDP: EOF".
- prompt.submit accepts surface=voice-live (+ voice_context) beside hud; the
  note rides the model input via the existing _prepend_note seam, the persisted
  user row stays the user's words, the system prompt stays byte-stable.
- config_defaults: voice.voice_chat_mode (chained|gpt-live), voice.gpt_live.*.

Desktop
- lib/voice-live.ts: RTCPeerConnection + oai-events data channel owner, transcript
  accumulation, session.commentary/thinking/instructions appends (500-token
  chunking), mute, graceful close waiting for session.closed.
- hooks/use-voice-live-conversation.ts: same public shape as useVoiceConversation;
  delegation → prompt.submit(surface=voice-live); tool activity → quiet thinking
  appends; reply streamed back per sentence; spoken stop phrase ends the chat;
  a newer delegation interrupts an in-flight turn.
- use-composer-voice mounts both engines and latches one at conversation start
  from the backend-resolved status; gpt-live without a key falls back to chained
  with a notice. Settings → Voice gets the mode dropdown, voice picker, persona.

Live-verified on the worktree desktop build (headless Electron, CDP, synthetic
mic): "what is 17 times 23 and which model are you on" → delegation → Hermes
(Claude Sonnet 4.5 via OpenRouter) → spoken "391 … Claude Sonnet 4.5 through
OpenRouter"; follow-up "double that" resolved from the spoken context → 782;
"run uname -r" ran the terminal tool with "Hermes is working: terminal" fed as
quiet context → spoken kernel version; "stop" closed the session
(reason=close_requested). Chained mode creates no RTCPeerConnection.
2026-09-11 19:14:24 -07:00

187 lines
9.1 KiB
Python

"""GPT-Live voice chat mode: the full-duplex voice frontend that delegates to Hermes.
``voice.voice_chat_mode: gpt-live`` replaces the chained STT → turn → TTS loop with ONE
full-duplex voice model (OpenAI ``gpt-live-1``) that owns the microphone and the speaker and
delegates every real request to Hermes as its *client-delegation* backend. Hermes stays the
agent: whatever model/provider the session has selected answers, with the full toolset.
Division of labour (the Live API has no tools of its own in client mode):
* the desktop renderer holds the WebRTC media session (mic in, speech out) and the data channel;
* this module resolves WHICH credentials/voice/persona to use and performs the one server-side
step the API requires — exchanging the browser's SDP offer for an answer with the project key
(``POST /v1/live/sessions``), so the key never reaches the client;
* the renderer turns each ``session.delegation.created`` into a normal ``prompt.submit`` on the
active session (surface ``voice-live``) and streams the reply back as
``session.commentary.append`` — Hermes' answer is what the voice speaks.
Vendor contract: https://developers.openai.com/api/docs/guides/live (+ live-delegation,
voice-webrtc). Billing is $0.05/min of session time on the OpenAI key, separate from the
Hermes turn.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
GPT_LIVE_MODE = "gpt-live"
CHAINED_MODE = "chained"
DEFAULT_LIVE_MODEL = "gpt-live-1"
DEFAULT_LIVE_VOICE = "marin"
DEFAULT_LIVE_BASE_URL = "https://api.openai.com/v1"
# Voices the vendor lists for gpt-live-1 (live-conversations guide) plus the realtime defaults it
# accepts; free text stays allowed for custom voices.
GPT_LIVE_VOICES = (
"marin", "cedar", "quartz", "ripple", "vesper", "willow", "stone", "gleam", "meridian",
"bossa", "tempo", "beacon", "delta", "cinder",
)
# Persona for the voice layer. Short on purpose: the live model has a small context window and
# the vendor guide asks for role + style + a labelled delegation policy, nothing more. The
# backend (Hermes) carries the real instructions, tools and memory.
LIVE_PERSONA = (
"You are Hermes, a calm and friendly voice assistant. Speak naturally at an unhurried pace. "
"Be clear and direct, not overly cheerful. If the user is frustrated, acknowledge it briefly "
"and focus on the next helpful step.\n\n"
"Backchannel policy: Use moderate backchannels. Acknowledge naturally without competing with "
"the main response.\n\n"
"Interruption policy: Stop speaking when the user interrupts. Listen to what they say.\n\n"
"Delegation policy:\n"
"Backend tools:\n"
"- Hermes agent: a full AI agent with tools — it can run commands, read and edit files, "
"browse the web, search, remember things across sessions, schedule tasks, and reason "
"carefully about anything. It is the one who actually does work and knows facts.\n\n"
"Delegate to the backend when:\n"
"- The user asks a question that needs facts, current information, or careful reasoning.\n"
"- The user asks you to do, check, find, make, fix, run or remember anything.\n"
"- A correction changes work already requested.\n\n"
"Do not delegate to the backend when:\n"
"- The user greets you, makes small talk, or asks you to repeat a result already provided.\n"
"- You need a brief clarification to understand the request.\n\n"
"Delegate before giving an answer that depends on backend work. Do not guess the result "
"while waiting; say briefly that you are checking, then wait for the result."
)
# Per-turn note prepended to the MODEL INPUT (never the byte-stable system prompt) when a turn
# arrives from the live voice layer. Same seam as the HUD note.
VOICE_LIVE_TURN_NOTE = (
"[Note: this message is a delegation from a live spoken conversation. The text is a voice "
"transcript (it may contain mis-hearings, hesitations and later corrections; use the latest "
"intent). Your reply will be spoken aloud by a voice model that paraphrases it: answer in plain "
"conversational sentences, keep it short (a few sentences unless the user asked for detail), no "
"markdown, no lists, no code blocks, no URLs read out character by character. Do the work with "
"your tools as usual; only the final facts need to be spoken. Do not claim an action succeeded "
"before it actually did.]"
)
def voice_live_turn_note(context: str = "") -> str:
"""The per-turn note plus, when the client sent one, the recent spoken exchange the delegation
refers to (the user's last words alone are often "yes" or "Thursday, not Friday")."""
context = context.strip()
if not context:
return VOICE_LIVE_TURN_NOTE
return f"{VOICE_LIVE_TURN_NOTE}\n[Recent spoken conversation, newest last:\n{context}]"
def _voice_section() -> Dict[str, Any]:
try:
from hermes_cli.config import load_config
voice = load_config().get("voice")
except Exception:
return {}
return voice if isinstance(voice, dict) else {}
def _live_section(voice: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
section = (voice if voice is not None else _voice_section()).get("gpt_live")
return section if isinstance(section, dict) else {}
def voice_chat_mode(voice: Optional[Dict[str, Any]] = None) -> str:
"""``chained`` (default) or ``gpt-live``. Accepts the underscore spelling too."""
raw = (voice if voice is not None else _voice_section()).get("voice_chat_mode")
mode = str(raw or CHAINED_MODE).strip().lower().replace("_", "-")
return GPT_LIVE_MODE if mode in {GPT_LIVE_MODE, "gptlive", "live"} else CHAINED_MODE
def _resolve_credentials(live: Dict[str, Any]) -> tuple[str, str]:
"""``(api_key, base_url)`` — ``voice.gpt_live.api_key`` first, else the same OpenAI audio
chain the STT/TTS providers use (``VOICE_TOOLS_OPENAI_KEY`` → ``OPENAI_API_KEY`` → pool).
The Nous-managed audio proxy does not carry ``/live/sessions``; this mode is direct-key only.
"""
from tools.tool_backend_helpers import resolve_openai_audio_api_key
api_key = str(live.get("api_key") or "").strip() or resolve_openai_audio_api_key()
base_url = str(live.get("base_url") or DEFAULT_LIVE_BASE_URL).strip().rstrip("/")
return api_key, base_url
def live_instructions(live: Optional[Dict[str, Any]] = None) -> str:
extra = str((live if live is not None else _live_section()).get("instructions") or "").strip()
return f"{LIVE_PERSONA}\n\n{extra}" if extra else LIVE_PERSONA
def resolve_gpt_live_status() -> Dict[str, Any]:
"""Non-secret readiness verdict for the client: which mode is selected and whether GPT-Live
can start (a key resolves). Never returns the key."""
voice = _voice_section()
mode = voice_chat_mode(voice)
live = _live_section(voice)
api_key, _base = _resolve_credentials(live)
return {
"mode": mode,
"available": bool(api_key),
"reason": None if api_key else "no OpenAI API key (set OPENAI_API_KEY or voice.gpt_live.api_key)",
"model": str(live.get("model") or DEFAULT_LIVE_MODEL),
"voice": str(live.get("voice") or DEFAULT_LIVE_VOICE),
}
def build_session_config(history: Optional[list] = None) -> Dict[str, Any]:
"""The ``session`` object for ``POST /v1/live/sessions`` (client delegation, WebRTC — the
transport negotiates the audio format, so none is set)."""
live = _live_section()
config: Dict[str, Any] = {
"model": str(live.get("model") or DEFAULT_LIVE_MODEL),
"instructions": live_instructions(live),
"audio": {"output": {"voice": str(live.get("voice") or DEFAULT_LIVE_VOICE)}},
"delegation": {"type": "client"},
}
if history:
config["input"] = history
return config
def create_webrtc_session(sdp_offer: str, history: Optional[list] = None) -> Dict[str, Any]:
"""Exchange the renderer's SDP offer for the Live session answer.
Returns the vendor response ``{"session": {"id": ...}, "transport": {"type": "webrtc",
"sdp": ...}}``. Raises ``ValueError`` for a missing key and ``RuntimeError`` (with the vendor
status/detail) for a rejected request.
"""
live = _live_section()
api_key, base_url = _resolve_credentials(live)
if not api_key:
raise ValueError("GPT-Live needs an OpenAI API key (OPENAI_API_KEY or voice.gpt_live.api_key)")
body = json.dumps({
"session": build_session_config(history),
"transport": {"type": "webrtc", "sdp": sdp_offer},
}).encode("utf-8")
req = urllib.request.Request(
f"{base_url}/live/sessions", data=body, method="POST",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:600]
logger.warning("GPT-Live session creation failed: %s %s", exc.code, detail)
raise RuntimeError(f"GPT-Live session creation failed ({exc.code}): {detail}") from exc