This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.hermes-voice: run hermes turns via /v1/runs so they survive hang-up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Hermes
co-author
Claude Opus 5.5
committer
lassulus
date (Oct 9, 2026, 11:45 PM CEST) commit accc002b parent da8318d0
+115 -67
+113 -66
tools/hermes-voice/hermes_voice.py
··· 3 3 The browser holds a WebRTC session with gpt-live-1, which listens and speaks at 4 4 the same time and chats on its own. Whenever the user asks for real work, the 5 5 voice model emits a client delegation; the page hands the recent transcript to 6 - this server, which runs it as an ordinary Hermes turn on the API server 7 - (/v1/chat/completions, one X-Hermes-Session-Id per conversation) and streams 8 - the reply back. The page then gives the answer to the voice model, which says 9 - it aloud. 6 + this server, which starts it as a Hermes run on the API server (POST /v1/runs, 7 + one Hermes session id per conversation), follows the run's event stream and 8 + streams the reply back. The page then gives the answer to the voice model, 9 + which says it aloud. 10 + 11 + A run lives independently of any HTTP connection, so a turn survives the page 12 + going away. A newer delegation for the same conversation stops the previous 13 + run (the user corrected or replaced the request); hanging up just ends the 14 + call, and the run finishes whatever the user asked for. 10 15 11 16 This server exists so neither secret reaches the browser: it exchanges the 12 17 page's SDP offer for a Live session with the OpenAI key, and it calls Hermes ··· 25 30 import os 26 31 import re 27 32 import socket 33 + import threading 28 34 import time 29 35 import urllib.error 30 36 import urllib.request ··· 47 53 # closes the session after this much inactivity. 48 54 DEFAULT_IDLE_SECONDS = 90 49 55 MAX_BODY = 256 * 1024 50 - # Hermes turns run tools for minutes; the API server streams tool progress, but 51 - # a single long tool call can be silent for a while. 52 - HERMES_TIMEOUT = 900 56 + # The run event stream sends a keepalive every 10s, so a read that stalls this 57 + # long means the API server is gone. 58 + EVENTS_READ_TIMEOUT = 60 53 59 SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") 54 - # Hermes run ids are "chatcmpl-<hex>", approval request ids hex. 60 + # Hermes run ids are "run_<hex>", approval request ids hex. 55 61 RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$") 56 62 57 63 # Frontend persona, adapted from Hermes's own (tools/voice_live.py): role, ··· 226 232 yield event, "\n".join(data) 227 233 228 234 229 - def hermes_turn(config, hermes_session, lines): 230 - """Run one Hermes turn; yield NDJSON-ready dicts (tool, delta, done).""" 231 - body = json.dumps( 232 - { 233 - "messages": [ 234 - {"role": "system", "content": config.backend_note()}, 235 - {"role": "user", "content": transcript_message(lines)}, 236 - ], 237 - "stream": True, 238 - } 239 - ).encode() 235 + def hermes_request(config, method, path, payload=None, timeout=30): 236 + """Open a request to the Hermes API server (the caller closes it).""" 240 237 request = urllib.request.Request( 241 - f"{config.hermes_url}/v1/chat/completions", 242 - data=body, 243 - method="POST", 238 + f"{config.hermes_url}{path}", 239 + data=None if payload is None else json.dumps(payload).encode(), 240 + method=method, 244 241 headers={ 245 242 "Authorization": f"Bearer {config.hermes_key}", 246 243 "Content-Type": "application/json", 247 - "X-Hermes-Session-Id": hermes_session, 248 244 }, 249 245 ) 250 - with urllib.request.urlopen(request, timeout=HERMES_TIMEOUT) as response: 251 - for event, data in iter_sse(response): 252 - if data == "[DONE]": 253 - return 246 + return urllib.request.urlopen(request, timeout=timeout) 247 + 248 + 249 + def hermes_call(config, method, path, payload=None): 250 + with hermes_request(config, method, path, payload) as response: 251 + return json.load(response) 252 + 253 + 254 + def hermes_start_run(config, hermes_session, lines): 255 + """Start one Hermes turn as a run; return its run id.""" 256 + # A plain string input: Hermes then loads the history of session_id itself. 257 + return hermes_call( 258 + config, 259 + "POST", 260 + "/v1/runs", 261 + { 262 + "input": transcript_message(lines), 263 + "instructions": config.backend_note(), 264 + "session_id": hermes_session, 265 + }, 266 + )["run_id"] 267 + 268 + 269 + def hermes_follow_run(config, run_id): 270 + """Follow a run's events; yield NDJSON-ready dicts (tool, delta, approval, 271 + done, error). Leaving early only detaches: the run keeps going.""" 272 + with hermes_request( 273 + config, "GET", f"/v1/runs/{run_id}/events", timeout=EVENTS_READ_TIMEOUT 274 + ) as response: 275 + for _, data in iter_sse(response): 254 276 payload = json.loads(data) 255 - if event == "hermes.tool.progress": 256 - yield { 257 - "type": "tool", 258 - "status": payload.get("status"), 259 - "label": payload.get("label") or payload.get("tool"), 260 - } 261 - continue 262 - if event == "approval.request": 263 - # Hermes blocks the turn until POST /v1/runs/{run_id}/approval 264 - # answers (or its own approval timeout denies). 277 + event = payload.get("event", "") 278 + if event in ("tool.started", "tool.completed"): 279 + status = "running" if event == "tool.started" else "completed" 280 + if payload.get("error"): 281 + status = "error" 282 + yield {"type": "tool", "status": status, "label": payload.get("tool")} 283 + elif event == "approval.request": 284 + # The run blocks until POST /v1/runs/{run_id}/approval answers. 265 285 yield { 266 286 "type": "approval", 267 - "run_id": payload.get("run_id"), 287 + "run_id": run_id, 268 288 "request_id": payload.get("request_id"), 269 289 "command": payload.get("command"), 270 290 "description": payload.get("description"), 271 291 } 272 - continue 273 - if event: 274 - continue 275 - for choice in payload.get("choices", []): 276 - text = choice.get("delta", {}).get("content") 277 - if text: 278 - yield {"type": "delta", "text": text} 279 - if choice.get("finish_reason"): 280 - done = {"type": "done", "finish_reason": choice["finish_reason"]} 281 - if "error" in payload: 282 - done["error"] = payload["error"].get("message") 283 - yield done 292 + elif event == "run.completed": 293 + # Only the final answer: message.delta also streams commentary. 294 + yield {"type": "delta", "text": payload.get("output") or ""} 295 + yield {"type": "done", "finish_reason": "stop"} 296 + return 297 + elif event in ("run.failed", "run.cancelled", "run.interrupted"): 298 + status = event.removeprefix("run.") 299 + error = payload.get("error") or f"Hermes run {status}" 300 + yield {"type": "done", "finish_reason": status, "error": error} 301 + return 302 + yield {"type": "error", "message": "Hermes event stream ended early"} 284 303 285 304 286 305 def hermes_approve(config, run_id, request_id, choice): 287 - """Answer a pending approval of a streaming Hermes turn.""" 306 + """Answer a pending approval of a Hermes run.""" 288 307 answer = {"choice": choice} 289 308 if request_id: 290 309 answer["request_id"] = request_id 291 - request = urllib.request.Request( 292 - f"{config.hermes_url}/v1/runs/{run_id}/approval", 293 - data=json.dumps(answer).encode(), 294 - method="POST", 295 - headers={ 296 - "Authorization": f"Bearer {config.hermes_key}", 297 - "Content-Type": "application/json", 298 - }, 299 - ) 300 - with urllib.request.urlopen(request, timeout=30) as response: 301 - return json.load(response) 310 + return hermes_call(config, "POST", f"/v1/runs/{run_id}/approval", answer) 311 + 312 + 313 + # The active run per Hermes session, so a newer delegation can stop it. 314 + RUNS = {} 315 + RUNS_LOCK = threading.Lock() 316 + 317 + 318 + def take_run(hermes_session, run_id=None): 319 + """Remove and return the session's run (only if it is run_id, when given).""" 320 + with RUNS_LOCK: 321 + if run_id is None or RUNS.get(hermes_session) == run_id: 322 + return RUNS.pop(hermes_session, None) 323 + return None 324 + 325 + 326 + def stop_run(config, run_id): 327 + """POST /v1/runs/{run_id}/stop; failures are only logged.""" 328 + try: 329 + hermes_call(config, "POST", f"/v1/runs/{run_id}/stop") 330 + log.info("stopped run %s", run_id) 331 + except (urllib.error.URLError, OSError, ValueError) as error: 332 + log.warning("stopping run %s failed: %s", run_id, error) 302 333 303 334 304 335 class Handler(BaseHTTPRequestHandler): ··· 423 454 if not isinstance(lines, list) or not lines: 424 455 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "lines required"}) 425 456 return 457 + # One run per conversation: a newer delegation (usually a correction) 458 + # supersedes and stops the previous run. 459 + previous = take_run(hermes_session) 460 + if previous: 461 + stop_run(self.config, previous) 462 + try: 463 + run_id = hermes_start_run(self.config, hermes_session, lines) 464 + except urllib.error.HTTPError as error: 465 + detail = error.read().decode("utf-8", "replace")[:400] 466 + self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes {error.code}: {detail}"}) 467 + return 468 + except (urllib.error.URLError, OSError, ValueError, KeyError) as error: 469 + self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes unreachable: {error}"}) 470 + return 471 + with RUNS_LOCK: 472 + RUNS[hermes_session] = run_id 426 473 self.send_response(HTTPStatus.OK) 427 474 self.send_header("Content-Type", "application/x-ndjson") 428 475 self.send_header("Cache-Control", "no-store") ··· 432 479 try: 433 480 started = time.monotonic() 434 481 tools = 0 435 - for item in hermes_turn(self.config, hermes_session, lines): 482 + for item in hermes_follow_run(self.config, run_id): 436 483 if item["type"] == "tool" and item["status"] == "running": 437 484 tools += 1 438 485 self.write_line(item) ··· 444 491 self.write_line({"type": "error", "message": f"Hermes {error.code}: {detail}"}) 445 492 except (urllib.error.URLError, OSError, ValueError) as error: 446 493 self.write_line({"type": "error", "message": f"Hermes unreachable: {error}"}) 494 + take_run(hermes_session, run_id) 447 495 except (BrokenPipeError, ConnectionResetError): 448 - # The page abandoned this delegation (a newer one superseded it). 449 - # Leaving the with-block closed the upstream stream, which makes 450 - # Hermes interrupt the turn. 496 + # The page went away; the run keeps going and stays registered, so 497 + # a later delegation still supersedes it. 451 498 log.info("delegation for %s abandoned by the page", hermes_session) 452 499 453 500 def handle_approve(self, request):
+2 -1
tools/hermes-voice/static/app.js
··· 308 308 309 309 async delegate(id) { 310 310 // One Hermes turn at a time: a newer delegation (usually a correction) 311 - // supersedes the running one, and abandoning the request interrupts it. 311 + // supersedes the running one, and the server stops the old run. 312 312 // While Hermes waits for an approval, the next delegation is the user's 313 313 // answer, not a new request: the turn must keep running. 314 314 if (this.approval) { ··· 536 536 clearTimeout(this.closeTimer); 537 537 clearInterval(this.idleTimer); 538 538 clearInterval(this.probe); 539 + // Only the stream is dropped; the Hermes run keeps going on the server. 539 540 if (this.delegation) this.delegation.controller.abort(); 540 541 this.audioContext?.close().catch(() => {}); 541 542 this.mic?.getTracks().forEach((track) => track.stop());