fix(relay): keep realtime voice heartbeat alive during long Hermes runs

The realtime voice agent killed a turn after ~90s of websocket silence
(client idle watchdog). The relay heartbeat stopped the moment
hermes_run_status left {running, waiting_for_confirmation}, so a long or
background Hermes run could starve it and trip the stall. The heartbeat
now continues while session.hermes_task is unfinished, and the spoken
progress repeat is raised 30s->90s and gated on a coarse status change so
tool-message churn no longer re-narrates.

Adds plugin/tests/test_realtime_heartbeat.py (11 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-06-21 20:23:46 -04:00
co-authored by Claude Opus 4.8
parent 11274ce51b
commit d1820fb606
2 changed files with 230 additions and 7 deletions
+80 -7
View File
@@ -103,7 +103,9 @@ _PLAYBACK_DRAIN_TIMEOUT_SECONDS = 2.5
_PRE_HERMES_STATUS_LEAD_SECONDS = 0.75
_HERMES_PROGRESS_INTERVAL_SECONDS = 5.0
_HERMES_SPOKEN_PROGRESS_AFTER_SECONDS = 15.0
_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS = 30.0
# Calmer cadence: only re-speak the SAME high-level status this far apart, and
# only when the coarse status actually changed (see _should_repeat_spoken_status).
_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS = 90.0
_RESUME_TTL_SECONDS = 30.0
# Max time a completed background result waits for the floor to clear before it
# is spoken anyway (ADR 33 Tier B result delivery).
@@ -2581,7 +2583,13 @@ class RealtimeAgentHandler:
await asyncio.sleep(_HERMES_PROGRESS_INTERVAL_SECONDS)
now = time.time()
status = session.hermes_run_status
if status not in {"running", "waiting_for_confirmation"}:
# Keep the heartbeat alive while the underlying run is still in
# flight, even if `status` transiently drifts off "running" — the
# client kills the turn after ~90s of websocket silence, so this is
# the one thing keeping a long/background run's socket warm.
if not _should_continue_heartbeat(
session.hermes_task, status, session_closed=session.closed
):
return
elapsed_seconds = now - started_at
message, status_key = _hermes_progress_status(session)
@@ -2589,19 +2597,21 @@ class RealtimeAgentHandler:
status_key.startswith("progress:")
and "drafting a response" in status_key.lower()
)
coarse_key = _coarse_spoken_status_key(status, status_key)
should_speak = (
speakable_progress
and elapsed_seconds >= _HERMES_SPOKEN_PROGRESS_AFTER_SECONDS
and session.floor.can_speak(FloorMouth.ANDROID_FILLER)
and (
session.hermes_last_spoken_progress_key != status_key
or now - session.hermes_last_spoken_progress_at
>= _HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS
and _should_repeat_spoken_status(
now,
session.hermes_last_spoken_progress_at,
session.hermes_last_spoken_progress_key,
coarse_key,
)
)
if should_speak:
session.hermes_last_spoken_progress_at = now
session.hermes_last_spoken_progress_key = status_key
session.hermes_last_spoken_progress_key = coarse_key
await self._send(
ws,
session,
@@ -3582,6 +3592,69 @@ def _tool_status_line(tool_name: str | None, *, started: bool) -> str:
return f"Finished {label}."
def _should_continue_heartbeat(
task: asyncio.Task[Any] | None,
status: str,
*,
session_closed: bool,
) -> bool:
"""Whether the Hermes run-progress heartbeat should keep ticking.
The heartbeat is the only thing keeping the realtime websocket from going
silent during a long/background Hermes run, and the client kills the turn
after ~90s of silence. So the heartbeat must NOT self-terminate just because
``hermes_run_status`` momentarily drifts off ``running`` (e.g. a status that
briefly reads ``completed``/``idle`` between SSE bursts on a still-running
background run). It keeps ticking while the underlying task is alive and the
socket is open; it only stops once the task is actually finished/None or the
session has closed.
"""
if session_closed:
return False
if task is not None and not task.done():
# The run is still in flight regardless of the transient status label.
return True
# No live task: fall back to the status. Keep ticking only while a run is
# genuinely active/awaiting input; otherwise the heartbeat has nothing to
# guard and should stop.
return status in {"running", "waiting_for_confirmation"}
def _coarse_spoken_status_key(status: str, status_key: str) -> str:
"""Collapse a fine-grained ``status_key`` to a coarse high-level key.
The repeat gate should fire on a *meaningful* status change, not on tool
*message* churn. ``status_key`` values like ``progress:<message text>`` vary
every time a tool emits a new line even though the high-level state ("Hermes
is working") is unchanged. Collapsing those to a single ``progress`` bucket
means message-only churn no longer re-flags ``should_speak`` on the repeat
cadence, while real transitions (entering/leaving a tool, confirmation,
drafting) still register.
"""
if status_key.startswith("progress:"):
return f"{status}:progress"
return f"{status}:{status_key}"
def _should_repeat_spoken_status(
now: float,
last_spoken_at: float,
last_coarse_key: str | None,
coarse_key: str,
*,
repeat_after_seconds: float = _HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS,
) -> bool:
"""Whether a *repeat* spoken-progress nudge is warranted.
Returns True when the coarse high-level status changed since the last spoken
progress, OR when the same coarse status has persisted past the (now calmer)
repeat window. A brand-new status (no prior spoken key) always qualifies.
"""
if last_coarse_key is None or coarse_key != last_coarse_key:
return True
return (now - last_spoken_at) >= repeat_after_seconds
def _hermes_progress_status(session: RealtimeAgentSession) -> tuple[str, str]:
if session.hermes_run_status == "waiting_for_confirmation":
return "Waiting for confirmation.", "confirmation"
+150
View File
@@ -0,0 +1,150 @@
"""Unit tests for the realtime-agent Hermes run-progress heartbeat helpers.
Covers two behaviours that keep a long/background Hermes voice turn alive and
calm (see broker.py `_send_hermes_run_progress`):
- `_should_continue_heartbeat`: the heartbeat must keep ticking while the
underlying run task is still in flight even if `hermes_run_status` transiently
drifts off "running" (otherwise the websocket goes silent and the client kills
the turn after ~90s). It only stops once the task is finished/None or the
session has closed.
- `_should_repeat_spoken_status` + `_coarse_spoken_status_key`: spoken progress
is re-flagged only on a *meaningful* high-level status change, or after the
(now calmer) repeat window — tool *message* churn within the same coarse state
no longer re-triggers speech.
"""
from __future__ import annotations
import unittest
from plugin.relay.realtime_agent.broker import (
_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS,
_coarse_spoken_status_key,
_should_continue_heartbeat,
_should_repeat_spoken_status,
)
class _FakeTask:
"""Minimal stand-in for an asyncio.Task exposing only `.done()`."""
def __init__(self, done: bool) -> None:
self._done = done
def done(self) -> bool:
return self._done
class HeartbeatContinuationTest(unittest.TestCase):
def test_continues_while_task_running_even_off_status(self) -> None:
# The run task is still in flight; status briefly read "completed" between
# SSE bursts on a background run. Heartbeat must NOT self-terminate.
running = _FakeTask(done=False)
for status in ("running", "waiting_for_confirmation", "completed", "idle", "error"):
self.assertTrue(
_should_continue_heartbeat(running, status, session_closed=False),
msg=f"should keep ticking for live task at status={status!r}",
)
def test_stops_when_session_closed_even_with_live_task(self) -> None:
running = _FakeTask(done=False)
self.assertFalse(
_should_continue_heartbeat(running, "running", session_closed=True)
)
def test_no_task_falls_back_to_status(self) -> None:
# No live task: keep ticking only while a run is genuinely active.
self.assertTrue(_should_continue_heartbeat(None, "running", session_closed=False))
self.assertTrue(
_should_continue_heartbeat(None, "waiting_for_confirmation", session_closed=False)
)
for status in ("completed", "idle", "cancelled", "error"):
self.assertFalse(
_should_continue_heartbeat(None, status, session_closed=False),
msg=f"no task + terminal status={status!r} should stop",
)
def test_finished_task_falls_back_to_status(self) -> None:
finished = _FakeTask(done=True)
# A finished task behaves like no task: status decides.
self.assertTrue(
_should_continue_heartbeat(finished, "running", session_closed=False)
)
self.assertFalse(
_should_continue_heartbeat(finished, "completed", session_closed=False)
)
class CoarseStatusKeyTest(unittest.TestCase):
def test_progress_messages_collapse_to_one_bucket(self) -> None:
# Two different tool *messages* under the same high-level status collapse
# to a single coarse key, so message churn doesn't re-trigger speech.
a = _coarse_spoken_status_key("running", "progress:Reading file foo.py")
b = _coarse_spoken_status_key("running", "progress:Reading file bar.py")
self.assertEqual(a, b)
self.assertEqual(a, "running:progress")
def test_distinct_tool_states_keep_distinct_keys(self) -> None:
self.assertNotEqual(
_coarse_spoken_status_key("running", "tool:search:running"),
_coarse_spoken_status_key("running", "tool:search:done"),
)
self.assertNotEqual(
_coarse_spoken_status_key("running", "tool:search:running"),
_coarse_spoken_status_key("waiting_for_confirmation", "confirmation"),
)
class SpokenStatusRepeatGateTest(unittest.TestCase):
def test_first_status_always_speaks(self) -> None:
self.assertTrue(
_should_repeat_spoken_status(
now=100.0,
last_spoken_at=0.0,
last_coarse_key=None,
coarse_key="running:tool:search:running",
)
)
def test_meaningful_change_speaks_immediately(self) -> None:
# Different coarse key -> speak even though almost no time has passed.
self.assertTrue(
_should_repeat_spoken_status(
now=101.0,
last_spoken_at=100.0,
last_coarse_key="running:tool:search:running",
coarse_key="running:tool:search:done",
)
)
def test_same_status_message_churn_does_not_respeak_early(self) -> None:
# Same coarse key, only a tool message churned, and the repeat window has
# NOT elapsed -> stay quiet.
self.assertFalse(
_should_repeat_spoken_status(
now=120.0,
last_spoken_at=100.0, # 20s < 90s repeat window
last_coarse_key="running:progress",
coarse_key="running:progress",
)
)
def test_same_status_respeaks_after_repeat_window(self) -> None:
# Same coarse key, but the (calm) repeat window elapsed -> a gentle nudge.
self.assertTrue(
_should_repeat_spoken_status(
now=100.0 + _HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS + 1.0,
last_spoken_at=100.0,
last_coarse_key="running:progress",
coarse_key="running:progress",
)
)
def test_repeat_window_is_calm(self) -> None:
# Guards the cadence relaxation: the repeat window is at least 90s.
self.assertGreaterEqual(_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS, 90.0)
if __name__ == "__main__":
unittest.main()