···33The browser holds a WebRTC session with gpt-live-1, which listens and speaks at
44the same time and chats on its own. Whenever the user asks for real work, the
55voice model emits a client delegation; the page hands the recent transcript to
66-this server, which runs it as an ordinary Hermes turn on the API server
77-(/v1/chat/completions, one X-Hermes-Session-Id per conversation) and streams
88-the reply back. The page then gives the answer to the voice model, which says
99-it aloud.
66+this server, which starts it as a Hermes run on the API server (POST /v1/runs,
77+one Hermes session id per conversation), follows the run's event stream and
88+streams the reply back. The page then gives the answer to the voice model,
99+which says it aloud.
1010+1111+A run lives independently of any HTTP connection, so a turn survives the page
1212+going away. A newer delegation for the same conversation stops the previous
1313+run (the user corrected or replaced the request); hanging up just ends the
1414+call, and the run finishes whatever the user asked for.
10151116This server exists so neither secret reaches the browser: it exchanges the
1217page's SDP offer for a Live session with the OpenAI key, and it calls Hermes
···2530import os
2631import re
2732import socket
3333+import threading
2834import time
2935import urllib.error
3036import urllib.request
···4753# closes the session after this much inactivity.
4854DEFAULT_IDLE_SECONDS = 90
4955MAX_BODY = 256 * 1024
5050-# Hermes turns run tools for minutes; the API server streams tool progress, but
5151-# a single long tool call can be silent for a while.
5252-HERMES_TIMEOUT = 900
5656+# The run event stream sends a keepalive every 10s, so a read that stalls this
5757+# long means the API server is gone.
5858+EVENTS_READ_TIMEOUT = 60
5359SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
5454-# Hermes run ids are "chatcmpl-<hex>", approval request ids hex.
6060+# Hermes run ids are "run_<hex>", approval request ids hex.
5561RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
56625763# Frontend persona, adapted from Hermes's own (tools/voice_live.py): role,
···226232 yield event, "\n".join(data)
227233228234229229-def hermes_turn(config, hermes_session, lines):
230230- """Run one Hermes turn; yield NDJSON-ready dicts (tool, delta, done)."""
231231- body = json.dumps(
232232- {
233233- "messages": [
234234- {"role": "system", "content": config.backend_note()},
235235- {"role": "user", "content": transcript_message(lines)},
236236- ],
237237- "stream": True,
238238- }
239239- ).encode()
235235+def hermes_request(config, method, path, payload=None, timeout=30):
236236+ """Open a request to the Hermes API server (the caller closes it)."""
240237 request = urllib.request.Request(
241241- f"{config.hermes_url}/v1/chat/completions",
242242- data=body,
243243- method="POST",
238238+ f"{config.hermes_url}{path}",
239239+ data=None if payload is None else json.dumps(payload).encode(),
240240+ method=method,
244241 headers={
245242 "Authorization": f"Bearer {config.hermes_key}",
246243 "Content-Type": "application/json",
247247- "X-Hermes-Session-Id": hermes_session,
248244 },
249245 )
250250- with urllib.request.urlopen(request, timeout=HERMES_TIMEOUT) as response:
251251- for event, data in iter_sse(response):
252252- if data == "[DONE]":
253253- return
246246+ return urllib.request.urlopen(request, timeout=timeout)
247247+248248+249249+def hermes_call(config, method, path, payload=None):
250250+ with hermes_request(config, method, path, payload) as response:
251251+ return json.load(response)
252252+253253+254254+def hermes_start_run(config, hermes_session, lines):
255255+ """Start one Hermes turn as a run; return its run id."""
256256+ # A plain string input: Hermes then loads the history of session_id itself.
257257+ return hermes_call(
258258+ config,
259259+ "POST",
260260+ "/v1/runs",
261261+ {
262262+ "input": transcript_message(lines),
263263+ "instructions": config.backend_note(),
264264+ "session_id": hermes_session,
265265+ },
266266+ )["run_id"]
267267+268268+269269+def hermes_follow_run(config, run_id):
270270+ """Follow a run's events; yield NDJSON-ready dicts (tool, delta, approval,
271271+ done, error). Leaving early only detaches: the run keeps going."""
272272+ with hermes_request(
273273+ config, "GET", f"/v1/runs/{run_id}/events", timeout=EVENTS_READ_TIMEOUT
274274+ ) as response:
275275+ for _, data in iter_sse(response):
254276 payload = json.loads(data)
255255- if event == "hermes.tool.progress":
256256- yield {
257257- "type": "tool",
258258- "status": payload.get("status"),
259259- "label": payload.get("label") or payload.get("tool"),
260260- }
261261- continue
262262- if event == "approval.request":
263263- # Hermes blocks the turn until POST /v1/runs/{run_id}/approval
264264- # answers (or its own approval timeout denies).
277277+ event = payload.get("event", "")
278278+ if event in ("tool.started", "tool.completed"):
279279+ status = "running" if event == "tool.started" else "completed"
280280+ if payload.get("error"):
281281+ status = "error"
282282+ yield {"type": "tool", "status": status, "label": payload.get("tool")}
283283+ elif event == "approval.request":
284284+ # The run blocks until POST /v1/runs/{run_id}/approval answers.
265285 yield {
266286 "type": "approval",
267267- "run_id": payload.get("run_id"),
287287+ "run_id": run_id,
268288 "request_id": payload.get("request_id"),
269289 "command": payload.get("command"),
270290 "description": payload.get("description"),
271291 }
272272- continue
273273- if event:
274274- continue
275275- for choice in payload.get("choices", []):
276276- text = choice.get("delta", {}).get("content")
277277- if text:
278278- yield {"type": "delta", "text": text}
279279- if choice.get("finish_reason"):
280280- done = {"type": "done", "finish_reason": choice["finish_reason"]}
281281- if "error" in payload:
282282- done["error"] = payload["error"].get("message")
283283- yield done
292292+ elif event == "run.completed":
293293+ # Only the final answer: message.delta also streams commentary.
294294+ yield {"type": "delta", "text": payload.get("output") or ""}
295295+ yield {"type": "done", "finish_reason": "stop"}
296296+ return
297297+ elif event in ("run.failed", "run.cancelled", "run.interrupted"):
298298+ status = event.removeprefix("run.")
299299+ error = payload.get("error") or f"Hermes run {status}"
300300+ yield {"type": "done", "finish_reason": status, "error": error}
301301+ return
302302+ yield {"type": "error", "message": "Hermes event stream ended early"}
284303285304286305def hermes_approve(config, run_id, request_id, choice):
287287- """Answer a pending approval of a streaming Hermes turn."""
306306+ """Answer a pending approval of a Hermes run."""
288307 answer = {"choice": choice}
289308 if request_id:
290309 answer["request_id"] = request_id
291291- request = urllib.request.Request(
292292- f"{config.hermes_url}/v1/runs/{run_id}/approval",
293293- data=json.dumps(answer).encode(),
294294- method="POST",
295295- headers={
296296- "Authorization": f"Bearer {config.hermes_key}",
297297- "Content-Type": "application/json",
298298- },
299299- )
300300- with urllib.request.urlopen(request, timeout=30) as response:
301301- return json.load(response)
310310+ return hermes_call(config, "POST", f"/v1/runs/{run_id}/approval", answer)
311311+312312+313313+# The active run per Hermes session, so a newer delegation can stop it.
314314+RUNS = {}
315315+RUNS_LOCK = threading.Lock()
316316+317317+318318+def take_run(hermes_session, run_id=None):
319319+ """Remove and return the session's run (only if it is run_id, when given)."""
320320+ with RUNS_LOCK:
321321+ if run_id is None or RUNS.get(hermes_session) == run_id:
322322+ return RUNS.pop(hermes_session, None)
323323+ return None
324324+325325+326326+def stop_run(config, run_id):
327327+ """POST /v1/runs/{run_id}/stop; failures are only logged."""
328328+ try:
329329+ hermes_call(config, "POST", f"/v1/runs/{run_id}/stop")
330330+ log.info("stopped run %s", run_id)
331331+ except (urllib.error.URLError, OSError, ValueError) as error:
332332+ log.warning("stopping run %s failed: %s", run_id, error)
302333303334304335class Handler(BaseHTTPRequestHandler):
···423454 if not isinstance(lines, list) or not lines:
424455 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "lines required"})
425456 return
457457+ # One run per conversation: a newer delegation (usually a correction)
458458+ # supersedes and stops the previous run.
459459+ previous = take_run(hermes_session)
460460+ if previous:
461461+ stop_run(self.config, previous)
462462+ try:
463463+ run_id = hermes_start_run(self.config, hermes_session, lines)
464464+ except urllib.error.HTTPError as error:
465465+ detail = error.read().decode("utf-8", "replace")[:400]
466466+ self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes {error.code}: {detail}"})
467467+ return
468468+ except (urllib.error.URLError, OSError, ValueError, KeyError) as error:
469469+ self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes unreachable: {error}"})
470470+ return
471471+ with RUNS_LOCK:
472472+ RUNS[hermes_session] = run_id
426473 self.send_response(HTTPStatus.OK)
427474 self.send_header("Content-Type", "application/x-ndjson")
428475 self.send_header("Cache-Control", "no-store")
···432479 try:
433480 started = time.monotonic()
434481 tools = 0
435435- for item in hermes_turn(self.config, hermes_session, lines):
482482+ for item in hermes_follow_run(self.config, run_id):
436483 if item["type"] == "tool" and item["status"] == "running":
437484 tools += 1
438485 self.write_line(item)
···444491 self.write_line({"type": "error", "message": f"Hermes {error.code}: {detail}"})
445492 except (urllib.error.URLError, OSError, ValueError) as error:
446493 self.write_line({"type": "error", "message": f"Hermes unreachable: {error}"})
494494+ take_run(hermes_session, run_id)
447495 except (BrokenPipeError, ConnectionResetError):
448448- # The page abandoned this delegation (a newer one superseded it).
449449- # Leaving the with-block closed the upstream stream, which makes
450450- # Hermes interrupt the turn.
496496+ # The page went away; the run keeps going and stays registered, so
497497+ # a later delegation still supersedes it.
451498 log.info("delegation for %s abandoned by the page", hermes_session)
452499453500 def handle_approve(self, request):
···308308309309 async delegate(id) {
310310 // One Hermes turn at a time: a newer delegation (usually a correction)
311311- // supersedes the running one, and abandoning the request interrupts it.
311311+ // supersedes the running one, and the server stops the old run.
312312 // While Hermes waits for an approval, the next delegation is the user's
313313 // answer, not a new request: the turn must keep running.
314314 if (this.approval) {
···536536 clearTimeout(this.closeTimer);
537537 clearInterval(this.idleTimer);
538538 clearInterval(this.probe);
539539+ // Only the stream is dropped; the Hermes run keeps going on the server.
539540 if (this.delegation) this.delegation.controller.abort();
540541 this.audioContext?.close().catch(() => {});
541542 this.mic?.getTracks().forEach((track) => track.stop());