feat(hermes): ship full-duplex voice release

This commit is contained in:
jenkins 2026-08-23 22:13:52 -03:00
parent d3cbeb06c3
commit d254931a14
36 changed files with 3603 additions and 140 deletions

View File

@ -114,7 +114,7 @@ spec:
string(
name: 'EXPECTED_SOURCE_REVISION',
defaultValue: '',
description: 'Full reviewed commit that must be contained by atlas/titan-iac main.'
description: 'Full reviewed commit already contained by atlas/titan-iac main.'
)
string(
name: 'CONFIRM_PUBLISH',
@ -152,9 +152,11 @@ spec:
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-agent
case "${BUILD_NUMBER}" in

View File

@ -126,9 +126,12 @@ spec:
*[!0-9a-f]*|'') echo 'EXPECTED_SOURCE_REVISION must be a lowercase full commit' >&2; exit 2 ;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse HEAD)"
test "${main_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
case "${BUILD_NUMBER}" in ''|0*|*[!0-9]*) exit 2 ;; esac
image="registry.bstein.dev/bstein/hermes-jetson-${IMAGE_COMPONENT}"
@ -147,7 +150,12 @@ spec:
set -eu
python3 -m pip install --disable-pip-version-check --no-cache-dir \
--target=/tmp/hermes-voice-test-deps pytest==8.3.4 PyYAML==6.0.2
python3 -m py_compile \
dockerfiles/hermes-jetson-stt-server.py \
dockerfiles/hermes-jetson-tts-server.py \
dockerfiles/hermes_jetson_tts_cues.py
PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \
testing/tests/test_hermes_stt_streaming.py \
testing/tests/test_hermes_tts_language_routing.py \
testing/tests/test_hermes_voice_language_routing.py \
testing/tests/test_hermes_oci_promote.py \

View File

@ -143,9 +143,12 @@ spec:
;;
esac
test "${#EXPECTED_SOURCE_REVISION}" -eq 40
main_revision="$(git rev-parse HEAD)"
test "${main_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${main_revision}"
git checkout --detach "${EXPECTED_SOURCE_REVISION}"
actual_revision="$(git rev-parse HEAD)"
test "${actual_revision}" = "$(git rev-parse origin/main)"
git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}" "${actual_revision}"
test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
test -z "$(git status --porcelain)"
test -f dockerfiles/Dockerfile.hermes-webui
case "${BUILD_NUMBER}" in
@ -172,6 +175,14 @@ spec:
pytest==8.3.4 PyYAML==6.0.2
PYTHONPATH=/tmp/hermes-webui-release-test-deps \
python3 -m pytest -q \
testing/tests/test_hermes_chat_quality.py \
testing/tests/test_hermes_handsfree_stt.py \
testing/tests/test_hermes_voice_instrument.py \
testing/tests/test_hermes_voice_full_duplex.py \
testing/tests/test_hermes_voice_route_preflight.py \
testing/tests/test_hermes_voice_preflight_delivery.py \
testing/tests/test_hermes_thinking_voice_cues.py \
testing/tests/test_hermes_voice_language_routing.py \
testing/tests/test_hermes_webui_brand.py \
testing/tests/test_hermes_webui_release.py \
testing/tests/test_hermes_oci_promote.py \

View File

@ -49,7 +49,8 @@ ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb55577
RUN chmod 0555 /opt/models /opt/models/piper
COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py
COPY dockerfiles/hermes_jetson_tts_cues.py /opt/atlas/hermes_jetson_tts_cues.py
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py /opt/atlas/hermes_jetson_tts_cues.py
# Load every pinned voice during the ARM64 build, including the three baked
# for the multilingual chat policy. This catches package or model-format

View File

@ -53,6 +53,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'settings["webui_bundle_version"]' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'settings.webui_bundle_version||settings.webui_version' /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Audio conversion failed: upload is invalid' /opt/hermes/tools/transcription_tools.py \
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
&& grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \

View File

@ -39,10 +39,96 @@ MAX_WS_MESSAGE_BYTES = 256 * 1024
STREAM_IDLE_SECONDS = int(os.getenv("HERMES_STT_STREAM_IDLE_SECONDS", "120"))
VAD_END_SILENCE_MS = int(os.getenv("HERMES_STT_VAD_END_SILENCE_MS", "650"))
VAD_TAIL_MS = int(os.getenv("HERMES_STT_VAD_TAIL_MS", "220"))
MODEL_LOCK = threading.Lock()
ROLLING_MIN_AUDIO_MS = max(
300,
min(2_000, int(os.getenv("HERMES_STT_ROLLING_MIN_AUDIO_MS", "650"))),
)
ROLLING_INTERVAL_MS = max(
400,
min(5_000, int(os.getenv("HERMES_STT_ROLLING_INTERVAL_MS", "750"))),
)
ROLLING_WINDOW_MS = max(
3_000,
min(30_000, int(os.getenv("HERMES_STT_ROLLING_WINDOW_MS", "12000"))),
)
ROLLING_MAX_RESULT_LAG_MS = max(
500,
min(5_000, int(os.getenv("HERMES_STT_ROLLING_MAX_LAG_MS", "2000"))),
)
_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
class InferenceGate:
"""Give authoritative transcripts priority over disposable partial work."""
def __init__(self) -> None:
self._condition = threading.Condition()
self._busy = False
self._final_waiters = 0
self._last_final_wait_ms = 0.0
self._last_final_decode_ms = 0.0
self._last_background_decode_ms = 0.0
self._background_skips = 0
def run_final(self, callback):
"""Wait fairly enough that newly arriving partials cannot steal the GPU."""
wait_started = time.monotonic()
with self._condition:
self._final_waiters += 1
try:
while self._busy:
self._condition.wait()
self._busy = True
finally:
self._final_waiters -= 1
self._last_final_wait_ms = (time.monotonic() - wait_started) * 1000
decode_started = time.monotonic()
try:
return callback()
finally:
with self._condition:
self._last_final_decode_ms = (
time.monotonic() - decode_started
) * 1000
self._busy = False
self._condition.notify_all()
def try_background(self, callback):
"""Run one useful predecode only when no final is active or waiting."""
with self._condition:
if self._busy or self._final_waiters:
self._background_skips += 1
return None
self._busy = True
decode_started = time.monotonic()
try:
return callback()
finally:
with self._condition:
self._last_background_decode_ms = (
time.monotonic() - decode_started
) * 1000
self._busy = False
self._condition.notify_all()
def snapshot(self) -> dict:
"""Expose bounded timing telemetry for live latency verification."""
with self._condition:
return {
"busy": self._busy,
"final_waiters": self._final_waiters,
"last_final_wait_ms": round(self._last_final_wait_ms, 1),
"last_final_decode_ms": round(self._last_final_decode_ms, 1),
"last_background_decode_ms": round(
self._last_background_decode_ms, 1
),
"background_skips": self._background_skips,
}
INFERENCE_GATE = InferenceGate()
def _repetitive_token(token: str) -> bool:
"""Identify long periodic Whisper hallucinations caused by steady noise."""
letters = "".join(re.findall(r"[a-z]+", token.lower()))
@ -112,37 +198,47 @@ def _transcription_payload(result: dict) -> dict:
}
def _decode_path(model: object, path: str, language: str) -> dict:
"""Decode one path with the quality settings shared by partial and final work."""
result = model.transcribe(
path,
language=None if language in {"", "auto"} else language,
task="transcribe",
fp16=torch.cuda.is_available(),
condition_on_previous_text=False,
temperature=0,
compression_ratio_threshold=2.0,
logprob_threshold=-0.8,
no_speech_threshold=0.5,
verbose=False,
)
return _transcription_payload(result)
def _transcribe_path(model: object, path: str, language: str) -> dict:
"""Run the one canonical full-utterance Whisper decode configuration."""
with MODEL_LOCK:
result = model.transcribe(
path,
language=None if language in {"", "auto"} else language,
task="transcribe",
fp16=torch.cuda.is_available(),
condition_on_previous_text=False,
temperature=0,
compression_ratio_threshold=2.0,
logprob_threshold=-0.8,
no_speech_threshold=0.5,
verbose=False,
)
return _transcription_payload(result)
return INFERENCE_GATE.run_final(lambda: _decode_path(model, path, language))
def _write_pcm_wav(pcm: bytes) -> str:
"""Write an aligned browser PCM snapshot to a temporary Whisper input."""
with tempfile.NamedTemporaryFile(
prefix="atlas-stt-stream-", suffix=".wav", delete=False
) as temp:
temp_path = temp.name
with wave.open(temp_path, "wb") as wav_file:
wav_file.setnchannels(STREAM_CHANNELS)
wav_file.setsampwidth(STREAM_SAMPLE_WIDTH)
wav_file.setframerate(STREAM_SAMPLE_RATE)
wav_file.writeframes(pcm)
return temp_path
def _transcribe_pcm(model: object, pcm: bytes, language: str) -> dict:
"""Decode one complete 16-kHz mono PCM snapshot through the canonical path."""
temp_path = ""
try:
with tempfile.NamedTemporaryFile(
prefix="atlas-stt-stream-", suffix=".wav", delete=False
) as temp:
temp_path = temp.name
with wave.open(temp_path, "wb") as wav_file:
wav_file.setnchannels(STREAM_CHANNELS)
wav_file.setsampwidth(STREAM_SAMPLE_WIDTH)
wav_file.setframerate(STREAM_SAMPLE_RATE)
wav_file.writeframes(pcm)
temp_path = _write_pcm_wav(pcm)
return _transcribe_path(model, temp_path, language)
finally:
if temp_path:
@ -152,6 +248,76 @@ def _transcribe_pcm(model: object, pcm: bytes, language: str) -> dict:
pass
def _transcribe_pcm_rolling(
model: object, pcm: bytes, language: str
) -> dict | None:
"""Decode a rolling window only when the single GPU worker is immediately free.
Partial work is disposable. It must never form an inference backlog ahead of
a final utterance or another user's request, so a busy model means this
snapshot is skipped and a later audio frame may try again.
"""
temp_path = ""
try:
temp_path = _write_pcm_wav(pcm)
return INFERENCE_GATE.try_background(
lambda: _decode_path(model, temp_path, language)
)
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
def _transcribe_pcm_speculative(
model: object, pcm: bytes, language: str
) -> dict | None:
"""Predecode one EOS snapshot only when the model is currently idle."""
temp_path = ""
try:
temp_path = _write_pcm_wav(pcm)
return INFERENCE_GATE.try_background(
lambda: _decode_path(model, temp_path, language)
)
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
def _pcm_bytes_for_ms(milliseconds: int) -> int:
"""Convert milliseconds to an aligned byte count for the stream format."""
return (
STREAM_SAMPLE_RATE
* STREAM_SAMPLE_WIDTH
* milliseconds
// 1000
// STREAM_SAMPLE_WIDTH
* STREAM_SAMPLE_WIDTH
)
def _token_identity(token: str) -> str:
"""Normalize punctuation/case drift before comparing consecutive decodes."""
return re.sub(r"[^\w']+", "", token.casefold(), flags=re.UNICODE)
def _stable_token_prefix(previous: str, current: str) -> str:
"""Return only tokens repeated in the same order by consecutive decodes."""
old_tokens = previous.split()
new_tokens = current.split()
agreed = 0
for old, new in zip(old_tokens, new_tokens):
if not _token_identity(old) or _token_identity(old) != _token_identity(new):
break
agreed += 1
return " ".join(new_tokens[:agreed])
class WebSocketError(Exception):
"""Protocol error carrying an RFC 6455 close code safe to expose."""
@ -300,6 +466,12 @@ class StreamingTranscription:
self._pending_key = ""
self._cached_key = ""
self._cached_payload: dict | None = None
self._rolling_inflight = False
self._rolling_last_started_at = 0.0
self._rolling_last_audio_bytes = 0
self._rolling_previous = ""
self._rolling_previous_window_start = 0
self._rolling_revision = 0
def _response(self, message_type: str, **values: object) -> dict:
return {"type": message_type, "turn_id": self.turn_id, **values}
@ -345,13 +517,125 @@ class StreamingTranscription:
key = f"{self._epoch}:{hashlib.sha256(pcm).hexdigest()}"
return pcm, key
def _rolling_snapshot_locked(self) -> tuple[bytes, int]:
"""Return a bounded tail window and its absolute start in milliseconds."""
window_bytes = _pcm_bytes_for_ms(ROLLING_WINDOW_MS)
start = max(0, len(self._pcm) - window_bytes)
start -= start % STREAM_SAMPLE_WIDTH
pcm = bytes(self._pcm[start:])
start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH)
return pcm, start_ms
def _reset_rolling_history_locked(self) -> None:
"""Discard provisional agreement after the client's speech epoch changes."""
self._rolling_previous = ""
self._rolling_previous_window_start = 0
# Audio before a resume belongs to an invalidated provisional epoch and
# must not immediately satisfy the next rolling interval by itself.
self._rolling_last_audio_bytes = len(self._pcm)
self._rolling_last_started_at = 0.0
def _begin_rolling_locked(
self, now: float
) -> tuple[bytes, int, int, int] | None:
"""Reserve one due rolling decode without ever queuing a second one."""
total_bytes = len(self._pcm)
minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS)
interval_bytes = _pcm_bytes_for_ms(ROLLING_INTERVAL_MS)
required_new_bytes = (
interval_bytes if self._rolling_last_audio_bytes else minimum_bytes
)
if (
self._rolling_inflight
or total_bytes < minimum_bytes
or total_bytes - self._rolling_last_audio_bytes < required_new_bytes
or (
self._rolling_last_started_at
and (now - self._rolling_last_started_at) * 1000
< ROLLING_INTERVAL_MS
)
):
return None
pcm, window_start_ms = self._rolling_snapshot_locked()
self._rolling_inflight = True
self._rolling_last_started_at = now
self._rolling_last_audio_bytes = total_bytes
return pcm, window_start_ms, total_bytes, self._epoch
def _start_rolling(
self,
pcm: bytes,
window_start_ms: int,
audio_bytes: int,
epoch: int,
) -> None:
"""Run one disposable partial decode and publish only a current result."""
def worker() -> None:
payload: dict | None = None
try:
payload = _transcribe_pcm_rolling(self.model, pcm, self.language)
except Exception as exc:
# Partial inference is best-effort. The canonical commit path
# remains authoritative and reports its own actionable errors.
print(f"[stt] rolling transcription skipped: {exc}", flush=True)
message: dict | None = None
with self._lock:
self._rolling_inflight = False
lag_bytes = max(0, len(self._pcm) - audio_bytes)
max_lag_bytes = _pcm_bytes_for_ms(ROLLING_MAX_RESULT_LAG_MS)
current = (
payload is not None
and not self._closed
and not self._committing
and not self._at_eos
and self._epoch == epoch
and lag_bytes <= max_lag_bytes
)
if current:
transcript = str(payload.get("text") or "")
stable = ""
if self._rolling_previous_window_start == window_start_ms:
stable = _stable_token_prefix(
self._rolling_previous,
transcript,
)
self._rolling_previous = transcript
self._rolling_previous_window_start = window_start_ms
self._rolling_revision += 1
message = self._response(
"partial",
transcript=transcript,
stable_transcript=stable,
language=payload.get("language") or "",
speculative=True,
rolling=True,
revision=self._rolling_revision,
epoch=epoch,
window_start_ms=window_start_ms,
)
self._lock.notify_all()
if message is not None:
try:
self.connection.send_json(message)
except (BrokenPipeError, ConnectionError, OSError):
self.cancel()
threading.Thread(
target=worker,
name=f"stt-rolling-{self.turn_id}",
daemon=True,
).start()
def append(self, pcm: bytes) -> None:
"""Append PCM, track server VAD and begin speculation at probable EOS."""
"""Append PCM while overlapping rolling understanding with active speech."""
if not self._started:
raise WebSocketError("start must precede audio", 1008)
if not pcm or len(pcm) % STREAM_SAMPLE_WIDTH:
raise WebSocketError("unaligned PCM audio", 1003)
auto_speculate = False
rolling: tuple[bytes, int, int, int] | None = None
with self._lock:
if self._closed or self._committing:
raise WebSocketError("turn is no longer accepting audio", 1008)
@ -368,10 +652,12 @@ class StreamingTranscription:
self._epoch += 1
self._cached_key = ""
self._cached_payload = None
self._reset_rolling_history_locked()
self._heard_speech = True
self._at_eos = False
self._silence_bytes = 0
self._last_speech_byte = len(self._pcm)
rolling = self._begin_rolling_locked(time.monotonic())
elif self._heard_speech:
self._silence_bytes += len(pcm)
required = (
@ -385,6 +671,8 @@ class StreamingTranscription:
auto_speculate = True
if auto_speculate:
self.speculate()
elif rolling is not None:
self._start_rolling(*rolling)
def resume(self) -> None:
"""Invalidate an EOS snapshot when client-side VAD hears resumed speech."""
@ -397,6 +685,7 @@ class StreamingTranscription:
self._client_active = True
self._cached_key = ""
self._cached_payload = None
self._reset_rolling_history_locked()
def speculate(self) -> None:
"""Decode a stable snapshot once; only an unchanged turn may consume it."""
@ -412,7 +701,7 @@ class StreamingTranscription:
def worker() -> None:
try:
payload = _transcribe_pcm(self.model, pcm, self.language)
payload = _transcribe_pcm_speculative(self.model, pcm, self.language)
except Exception as exc:
print(f"[stt] speculative transcription failed: {exc}", flush=True)
with self._lock:
@ -423,6 +712,13 @@ class StreamingTranscription:
self.error("speculative transcription failed")
return
if payload is None:
with self._lock:
if self._pending_key == key:
self._pending_key = ""
self._lock.notify_all()
return
should_send = False
with self._lock:
if self._pending_key == key:
@ -531,6 +827,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
"ok": True,
"model": MODEL_NAME,
"device": "cuda" if torch.cuda.is_available() else "cpu",
"inference": INFERENCE_GATE.snapshot(),
"streaming": {
"enabled": True,
"path": "/v1/audio/transcriptions/stream",
@ -539,6 +836,12 @@ class SpeechHandler(BaseHTTPRequestHandler):
"max_seconds": MAX_STREAM_SECONDS,
"server_vad": True,
"speculative": True,
"rolling": {
"enabled": True,
"interval_ms": ROLLING_INTERVAL_MS,
"window_ms": ROLLING_WINDOW_MS,
"stable_prefix": True,
},
},
},
)

View File

@ -7,6 +7,7 @@ import io
import json
import os
import re
import struct
import threading
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -15,6 +16,8 @@ from pathlib import Path
import onnxruntime
from piper import PiperConfig, PiperVoice, SynthesisConfig
from hermes_jetson_tts_cues import build_cue_cache, resolve_cached_cue, write_cached_cue
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
@ -29,6 +32,25 @@ STREAM_WRITE_TIMEOUT_SECONDS = max(
VOICE_LOCK = threading.Lock()
TURN_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]{1,128}\Z")
# Piper deliberately leaves a generous tail after sentence punctuation. That
# sounds natural when one synthesis result is played in isolation, but hands-
# free mode queues many independently synthesized clauses and compounds those
# tails with the browser's punctuation cadence. Only silence after the last
# audible 5 ms window is shortened; voiced samples are never faded or removed.
SILENCE_WINDOW_MS = 5
SILENCE_ABS_THRESHOLD = 32
SILENCE_TRIM_TOLERANCE_MS = 20
CLAUSE_PAUSE_MS = 90
COLON_PAUSE_MS = 110
QUESTION_PAUSE_MS = 170
PERIOD_PAUSE_MS = 190
ELLIPSIS_PAUSE_MS = 220
PARAGRAPH_PAUSE_MS = 280
UNPUNCTUATED_PAUSE_MS = 70
SENTENCE_BOUNDARY_PATTERN = re.compile(
r"(?P<punct>\.{1,3}|[!?…]+)(?:[\"'”’)}\]]+)?(?P<gap>\s+|$)"
)
# Fixed, allow-listed language -> baked voice mapping. This is the ONLY path
# from a client-supplied string to a model name: client input is looked up
# here and never used to build a filesystem path directly. Both "-" and "_"
@ -79,6 +101,111 @@ def normalize_turn_id(value: object) -> str | None:
return normalized
def trailing_pause_ms(text: str) -> int:
"""Return the natural trailing pause budget for one spoken text unit."""
if re.search(r"\n\s*\n\s*$", text):
return PARAGRAPH_PAUSE_MS
terminal = text.rstrip().rstrip("\"'”’)}]")
if terminal.endswith(("...", "")):
return ELLIPSIS_PAUSE_MS
if terminal.endswith("."):
return PERIOD_PAUSE_MS
if terminal.endswith(("?", "!")):
return QUESTION_PAUSE_MS
if terminal.endswith((":", ";")):
return COLON_PAUSE_MS
if terminal.endswith(","):
return CLAUSE_PAUSE_MS
return UNPUNCTUATED_PAUSE_MS
def sentence_pause_targets(text: str) -> list[int]:
"""Map Piper's likely sentence results to punctuation-aware pause budgets."""
targets: list[int] = []
cursor = 0
for match in SENTENCE_BOUNDARY_PATTERN.finditer(text):
unit = text[cursor : match.end()]
targets.append(trailing_pause_ms(unit))
cursor = match.end()
if text[cursor:].strip() or not targets:
targets.append(trailing_pause_ms(text[cursor:] or text))
return targets
def trim_trailing_pcm_silence(
pcm: bytes | bytearray | memoryview,
sample_rate: int,
pause_ms: int,
) -> memoryview:
"""Shorten only a confirmed PCM silence tail to the requested duration.
The backward scan is restricted to the trailing envelope. Returning a
memoryview avoids copying sentence audio on the latency-sensitive stream
path. All-silent input is retained because it carries no safe phoneme
boundary from which to measure a pause.
"""
raw = memoryview(pcm).cast("B")
if len(raw) < 2 or len(raw) % 2 or sample_rate <= 0:
return raw
window_samples = max(1, sample_rate * SILENCE_WINDOW_MS // 1000)
window_bytes = window_samples * 2
active_end: int | None = None
cursor = len(raw)
while cursor > 0:
start = max(0, cursor - window_bytes)
# Piper PCM is explicitly signed 16-bit little endian. A conservative
# threshold preserves quiet word endings while ignoring quantization
# noise in the generated tail.
if any(
abs(struct.unpack_from("<h", raw, offset)[0]) > SILENCE_ABS_THRESHOLD
for offset in range(start, cursor, 2)
):
active_end = cursor
break
cursor = start
if active_end is None:
return raw
desired_tail_bytes = max(0, sample_rate * pause_ms // 1000) * 2
cutoff = min(len(raw), active_end + desired_tail_bytes)
tolerance_bytes = max(0, sample_rate * SILENCE_TRIM_TOLERANCE_MS // 1000) * 2
if len(raw) - cutoff <= tolerance_bytes:
return raw
return raw[:cutoff]
def normalize_wav_trailing_pause(audio: bytes, text: str) -> bytes:
"""Apply the same safe silence-tail policy to the compatibility WAV path."""
source = io.BytesIO(audio)
try:
with wave.open(source, "rb") as reader:
params = reader.getparams()
if (
params.nchannels != 1
or params.sampwidth != 2
or params.comptype != "NONE"
):
return audio
pcm = reader.readframes(params.nframes)
except (EOFError, wave.Error):
return audio
normalized = trim_trailing_pcm_silence(
pcm,
params.framerate,
trailing_pause_ms(text),
)
if len(normalized) == len(pcm):
return audio
output = io.BytesIO()
with wave.open(output, "wb") as writer:
writer.setparams(params)
writer.writeframes(normalized)
return output.getvalue()
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
@ -114,6 +241,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
"path": "/v1/audio/speech/stream",
"format": "pcm_s16le",
},
"thinking_cues": len(self.server.cue_cache), # type: ignore[attr-defined]
},
)
@ -146,6 +274,21 @@ class SpeechHandler(BaseHTTPRequestHandler):
return
speed = min(2.0, max(0.5, speed))
try:
cue = resolve_cached_cue(
payload,
getattr(self.server, "cue_cache", {}),
)
except ValueError as exc:
_json(self, 400, {"error": str(exc)})
return
if cue is not None:
if self.path != "/v1/audio/speech/stream":
_json(self, 400, {"error": "thinking cues require PCM streaming"})
return
write_cached_cue(self, cue, normalize_turn_id(payload.get("turn_id")))
return
# Policy is driven ONLY by "language". A client-supplied "voice"
# field is deliberately never read here; it cannot override the
# allow-listed mapping.
@ -175,7 +318,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
wav_file,
SynthesisConfig(length_scale=1.0 / speed),
)
audio = output.getvalue()
audio = normalize_wav_trailing_pause(output.getvalue(), text)
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
@ -241,12 +384,14 @@ class SpeechHandler(BaseHTTPRequestHandler):
connected = True
try:
pause_targets = sentence_pause_targets(text)
audio_chunks = iter(
voice.synthesize(
text,
SynthesisConfig(length_scale=1.0 / speed),
)
)
chunk_index = 0
while connected:
# Serialize Piper/ONNX access, but never hold the model lock
# while a slow browser applies network back-pressure.
@ -261,7 +406,13 @@ class SpeechHandler(BaseHTTPRequestHandler):
or audio_chunk.sample_channels != 1
):
raise RuntimeError("Piper returned an unexpected PCM format")
pcm = memoryview(audio_chunk.audio_int16_bytes)
pause_ms = pause_targets[min(chunk_index, len(pause_targets) - 1)]
pcm = trim_trailing_pcm_silence(
audio_chunk.audio_int16_bytes,
sample_rate,
pause_ms,
)
chunk_index += 1
for offset in range(0, len(pcm), STREAM_WRITE_BYTES):
if not self._write_stream_chunk(pcm[offset : offset + STREAM_WRITE_BYTES]):
connected = False
@ -322,6 +473,12 @@ def main() -> None:
f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}"
)
voices = load_voices(CACHE_DIR, ONNX_THREADS)
cue_cache = build_cue_cache(
voices,
SynthesisConfig,
trim_trailing_pcm_silence,
trailing_pause_ms,
)
print(
f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: "
+ ", ".join(sorted(voices)),
@ -330,6 +487,7 @@ def main() -> None:
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
server.voices = voices # type: ignore[attr-defined]
server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined]
server.cue_cache = cue_cache # type: ignore[attr-defined]
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)

View File

@ -313,6 +313,7 @@ replace_exact(
routes,
"def _tts_open(req, *, timeout=30, opener_factory=None):",
'''ATLAS_TTS_LANGUAGES = ("en", "ru", "es")
ATLAS_TTS_CUE_IDS = ("thinking", "let_me_think", "still_working", "one_more_moment")
def _atlas_tts_language(body):
@ -343,9 +344,12 @@ replace_exact(
"def _tts_open(req, *, timeout=30, opener_factory=None):",
'''ATLAS_TTS_STREAM_URL = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech/stream"
ATLAS_STT_STREAM_URL = "http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions/stream"
ATLAS_VOICE_PREFLIGHT_URL = "http://hermes-switchyard.hermes.svc.cluster.local:9009/voice/route-preflight"
ATLAS_VOICE_WS_PROTOCOL = "hermes-voice-v1"
ATLAS_VOICE_MAX_BYTES = 8 * 1024 * 1024
ATLAS_VOICE_DEADLINE_SECONDS = 100
ATLAS_VOICE_PREFLIGHT_TIERS = ("fast", "balanced", "deep", "maximum")
ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES = 1024
def _atlas_exact_stream_url(env_name, expected):
@ -358,6 +362,7 @@ def _handle_atlas_streaming_capability(handler):
"""Advertise only transports whose immutable in-cluster URLs are configured."""
tts = bool(_atlas_exact_stream_url("HERMES_WEBUI_ATLAS_TTS_STREAM_URL", ATLAS_TTS_STREAM_URL))
stt = bool(_atlas_exact_stream_url("HERMES_WEBUI_ATLAS_STT_STREAM_URL", ATLAS_STT_STREAM_URL))
preflight = True
j(handler, {
"tts": {
"available": tts,
@ -372,10 +377,85 @@ def _handle_atlas_streaming_capability(handler):
"format": "pcm_s16le",
"sample_rate": 16000,
},
"preflight": {
"available": preflight,
"path": "/api/voice/route-preflight",
"advisory": True,
},
}, extra_headers={"Cache-Control": "no-store"})
return True
def _atlas_voice_preflight_payload(data):
"""Validate a provisional transcript without accepting routing authority."""
if not isinstance(data, dict):
raise ValueError("invalid request body")
turn_id = data.get("turn_id")
revision = data.get("revision")
transcript = data.get("transcript")
if not isinstance(turn_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", turn_id):
raise ValueError("invalid turn_id")
if isinstance(revision, bool) or not isinstance(revision, int) or not 1 <= revision <= 1000000:
raise ValueError("invalid revision")
if not isinstance(transcript, str):
raise ValueError("invalid transcript")
transcript = " ".join(transcript.split())
if not 12 <= len(transcript) <= 512:
raise ValueError("invalid transcript")
return {"turn_id": turn_id, "revision": revision, "transcript": transcript}
def _atlas_voice_preflight_open(request, timeout=1.0):
"""Open only the immutable in-cluster advisory endpoint without proxies."""
return build_opener(ProxyHandler({}), _NoRedirectTtsHandler()).open(request, timeout=timeout)
def _handle_atlas_voice_preflight(handler):
"""Relay one same-origin local advisory and expose no classifier output."""
if not _check_same_origin_browser_request(handler):
return bad(handler, "Voice route preflight origin validation failed", 403)
target = ATLAS_VOICE_PREFLIGHT_URL
try:
payload = _atlas_voice_preflight_payload(read_body(handler))
except (TypeError, ValueError) as exc:
return bad(handler, str(exc), 400)
request = Request(
target,
data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
try:
with _atlas_voice_preflight_open(request, timeout=1.0) as upstream:
raw = upstream.read(ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES + 1)
if len(raw) > ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES:
raise ValueError("oversized advisory response")
result = json.loads(raw)
tier = result.get("tier") if isinstance(result, dict) else None
target_hint = result.get("target") if isinstance(result, dict) else None
if (
result.get("turn_id") != payload["turn_id"]
or result.get("revision") != payload["revision"]
or tier not in ATLAS_VOICE_PREFLIGHT_TIERS
or target_hint != "atlas/auto/" + tier
or result.get("advisory") is not True
):
raise ValueError("invalid advisory response")
except Exception:
# Advisory failure never changes or delays the final Switchyard request.
return bad(handler, "Voice route preflight unavailable", 503)
return j(
handler,
{
"turn_id": payload["turn_id"],
"revision": payload["revision"],
"tier": tier,
"target": target_hint,
"advisory": True,
},
extra_headers={"Cache-Control": "no-store"},
)
def _atlas_tts_stream_payload(data):
"""Build the narrow Piper payload used by the raw-PCM stream endpoint."""
if not isinstance(data, dict):
@ -390,8 +470,13 @@ def _atlas_tts_stream_payload(data):
language = _atlas_tts_language(data)
if language:
payload["language"] = language
cue_id = data.get("cue_id")
if "cue_id" in data:
if not language or cue_id not in ATLAS_TTS_CUE_IDS:
raise ValueError("invalid thinking cue")
payload["cue_id"] = cue_id
turn_id = data.get("turn_id")
if isinstance(turn_id, str) and re.fullmatch(r"[A-Za-z0-9._-]{1,64}", turn_id):
if isinstance(turn_id, str) and re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", turn_id):
payload["turn_id"] = turn_id
return payload
@ -636,6 +721,9 @@ replace_exact(
''' if parsed.path == "/api/transcribe":
return handle_transcribe(handler)
if parsed.path == "/api/voice/route-preflight":
return _handle_atlas_voice_preflight(handler)
if parsed.path == "/api/tts/stream":
return _handle_atlas_tts_stream(handler)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Stamp Atlas WebUI shell URLs with the immutable image release identity."""
"""Bind the WebUI client and server to one immutable release identity."""
from __future__ import annotations
@ -23,3 +23,60 @@ for relative in ("static/index.html", "static/sw.js"):
source.replace("__WEBUI_VERSION__", f"__WEBUI_VERSION__-{RELEASE_ID}"),
encoding="utf-8",
)
routes = ROOT / "api/routes.py"
routes_source = routes.read_text(encoding="utf-8")
routes_marker = ' settings["webui_version"] = WEBUI_VERSION\n'
if routes_source.count(routes_marker) != 1:
raise SystemExit(f"Hermes release settings context changed in {routes}")
routes.write_text(
routes_source.replace(
routes_marker,
routes_marker
+ " settings[\"webui_bundle_version\"] = "
+ f'f"{{WEBUI_VERSION}}-{RELEASE_ID}"\n',
),
encoding="utf-8",
)
panels = ROOT / "static/panels.js"
panels_source = panels.read_text(encoding="utf-8")
server_marker = (
" const server=_normalizeWebUIVersion(settings.webui_version);\n"
)
server_replacement = (
" const server=_normalizeWebUIVersion("
"settings.webui_bundle_version||settings.webui_version);\n"
)
if panels_source.count(server_marker) != 1:
raise SystemExit(f"Hermes release skew context changed in {panels}")
matching_marker = " if(client===server) return;\n"
matching_replacement = (
" if(client===server){\n"
" const banner=document.getElementById('staleClientBanner');\n"
" if(banner) banner.style.display='none';\n"
" return;\n"
" }\n"
)
if panels_source.count(matching_marker) != 1:
raise SystemExit(f"Hermes release match context changed in {panels}")
poll_marker = " if(_isBannerVisible()) return;\n"
if panels_source.count(poll_marker) != 1:
raise SystemExit(f"Hermes release poll context changed in {panels}")
visible_stop_marker = (
" if(_isBannerVisible()){ clearInterval(_pollTimer); "
"_pollTimer=null; return; }\n"
)
if panels_source.count(visible_stop_marker) != 1:
raise SystemExit(f"Hermes release monitor context changed in {panels}")
panels.write_text(
panels_source.replace(server_marker, server_replacement)
.replace(matching_marker, matching_replacement)
.replace(poll_marker, "")
.replace(visible_stop_marker, ""),
encoding="utf-8",
)

View File

@ -4,6 +4,7 @@
from __future__ import annotations
import json
import re
import sys
from collections.abc import Callable
from typing import Any
@ -95,6 +96,34 @@ def smoke(
raise RuntimeError(f"served / omitted Hermes persona link: {source}")
if "static/hermes-brand.css" not in root_html:
raise RuntimeError("served / omitted static/hermes-brand.css")
version_match = re.search(
r"window\.__HERMES_WEBUI_BUNDLE_VERSION__='([^']+)'",
root_html,
)
if not version_match:
raise RuntimeError("served / omitted the WebUI bundle identity")
client_version = version_match.group(1)
settings_body, _settings_url = _get(
urljoin(base_url, "api/settings"), opener
)
try:
settings = json.loads(settings_body)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("served /api/settings is not valid JSON") from exc
server_version = settings.get("webui_bundle_version")
if not isinstance(server_version, str) or server_version != client_version:
raise RuntimeError(
"served WebUI client/server release identities do not match"
)
worker_body, _worker_url = _get(urljoin(base_url, "sw.js"), opener)
try:
worker = worker_body.decode("utf-8")
except UnicodeDecodeError as exc:
raise RuntimeError("served /sw.js is not UTF-8 JavaScript") from exc
if f"hermes-shell-{client_version}" not in worker:
raise RuntimeError("served /sw.js uses a different release identity")
direct_icon, _direct_url = _get(
urljoin(base_url, "static/hermes-agent-192.png"), opener
@ -107,6 +136,7 @@ def smoke(
"icon_requests": len(icon_sources),
"root": base_url,
"direct_icon": "static/hermes-agent-192.png",
"release_identity": client_version,
}
@ -118,6 +148,7 @@ def main() -> int:
"Hermes WebUI smoke passed: "
f"manifest={result['manifest']} "
f"icon_requests={result['icon_requests']} "
f"release_identity={result['release_identity']} "
"root=branded direct_icon=png"
)
return 0

View File

@ -0,0 +1,119 @@
"""Precompute the small, localized Hermes thinking-cue audio set."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
CUE_TEXT = {
"en": {
"thinking": "I'm thinking.",
"let_me_think": "Let me think.",
"still_working": "Still working on that.",
"one_more_moment": "One more moment.",
},
"ru": {
"thinking": "Я думаю.",
"let_me_think": "Дайте подумать.",
"still_working": "Я всё ещё думаю над этим.",
"one_more_moment": "Ещё мгновение.",
},
"es": {
"thinking": "Estoy pensando.",
"let_me_think": "Déjame pensar.",
"still_working": "Sigo pensando en eso.",
"one_more_moment": "Un momento más.",
},
}
LANGUAGE_VOICE = {
"en": "en_US-amy-medium",
"ru": "ru_RU-irina-medium",
"es": "es_MX-claude-high",
}
@dataclass(frozen=True)
class CachedCue:
"""One immutable PCM response synthesized before the server becomes ready."""
cue_id: str
language: str
voice_name: str
sample_rate: int
pcm: bytes
def build_cue_cache(
voices: dict,
synthesis_config_factory: Callable[..., object],
normalize_pcm: Callable[[object, int, int], object],
pause_for_text: Callable[[str], int],
) -> dict[tuple[str, str], CachedCue]:
"""Synthesize every allow-listed cue once, failing startup on voice drift."""
cache: dict[tuple[str, str], CachedCue] = {}
for language, entries in CUE_TEXT.items():
voice_name = LANGUAGE_VOICE[language]
voice = voices[voice_name]
sample_rate = int(voice.config.sample_rate)
for cue_id, text in entries.items():
chunks: list[bytes] = []
for result in voice.synthesize(text, synthesis_config_factory(length_scale=1.0)):
if (
result.sample_rate != sample_rate
or result.sample_width != 2
or result.sample_channels != 1
):
raise RuntimeError("Piper returned an unexpected cue PCM format")
chunks.append(
bytes(
normalize_pcm(
result.audio_int16_bytes,
sample_rate,
pause_for_text(text),
)
)
)
if not chunks:
raise RuntimeError(f"Piper returned no audio for cue {language}/{cue_id}")
cache[(language, cue_id)] = CachedCue(
cue_id, language, voice_name, sample_rate, b"".join(chunks)
)
return cache
def resolve_cached_cue(payload: object, cache: dict) -> CachedCue | None:
"""Resolve only an exact language/cue ID pair; client text is never trusted."""
if not isinstance(payload, dict) or "cue_id" not in payload:
return None
language = payload.get("language")
cue_id = payload.get("cue_id")
if not isinstance(language, str) or not isinstance(cue_id, str):
raise ValueError("invalid thinking cue")
cue = cache.get((language, cue_id))
if cue is None:
raise ValueError("unknown thinking cue")
return cue
def write_cached_cue(handler, cue: CachedCue, turn_id: str | None) -> None:
"""Return immutable PCM without acquiring the live Piper model lock."""
handler.send_response(200)
handler.send_header("Content-Type", "audio/pcm")
handler.send_header("Content-Length", str(len(cue.pcm)))
handler.send_header("Cache-Control", "private, max-age=86400, immutable")
handler.send_header("X-Audio-Format", "pcm_s16le")
handler.send_header("X-Audio-Sample-Rate", str(cue.sample_rate))
handler.send_header("X-Audio-Channels", "1")
handler.send_header("X-Audio-Sample-Width", "2")
handler.send_header("X-TTS-Voice", cue.voice_name)
handler.send_header("X-TTS-Cue-ID", cue.cue_id)
handler.send_header("X-TTS-Cache", "HIT")
if turn_id is not None:
handler.send_header("X-TTS-Turn-ID", turn_id)
handler.end_headers()
try:
handler.wfile.write(cue.pcm)
handler.wfile.flush()
except (BrokenPipeError, ConnectionResetError, TimeoutError, OSError):
handler.close_connection = True

View File

@ -134,6 +134,7 @@ configMapGenerator:
- configure_agent_clients.py=scripts/configure_agent_clients.py
- codex_broker.py=scripts/codex_broker.py
- classifier_broker.py=scripts/classifier_broker.py
- voice_route_preflight.py=scripts/voice_route_preflight.py
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
- worker_route_broker.py=scripts/worker_route_broker.py
- hermes_coordinator.py=scripts/hermes_coordinator.py

View File

@ -405,6 +405,7 @@ spec:
app: hermes-switchyard
ports:
- {protocol: TCP, port: 9005}
- {protocol: TCP, port: 9009}
- to:
- podSelector:
matchLabels:
@ -457,6 +458,12 @@ spec:
values: [hermes, hermes-agent, hermes-chat-tenant]
ports:
- {protocol: TCP, port: 9005}
- from:
- podSelector:
matchLabels:
app: hermes-chat-tenant
ports:
- {protocol: TCP, port: 9009}
- from:
- namespaceSelector:
matchLabels:
@ -466,6 +473,7 @@ spec:
app: server
ports:
- {protocol: TCP, port: 9005}
- {protocol: TCP, port: 9009}
egress:
- to:
- namespaceSelector:

View File

@ -6,14 +6,29 @@ from __future__ import annotations
import copy
import json
import os
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Final
import httpx
try:
from voice_route_preflight import (
COORDINATOR as VOICE_PREFLIGHT,
MAX_BODY_BYTES as VOICE_PREFLIGHT_MAX_BODY_BYTES,
validate_request as validate_voice_preflight_request,
)
except ModuleNotFoundError: # Test imports use the repository package path.
from services.hermes.scripts.voice_route_preflight import (
COORDINATOR as VOICE_PREFLIGHT,
MAX_BODY_BYTES as VOICE_PREFLIGHT_MAX_BODY_BYTES,
validate_request as validate_voice_preflight_request,
)
HOST: Final = os.environ.get("HERMES_CLASSIFIER_BROKER_HOST", "0.0.0.0")
PORT: Final = int(os.environ.get("HERMES_CLASSIFIER_BROKER_PORT", "9008"))
VOICE_PORT: Final = int(os.environ.get("HERMES_VOICE_PREFLIGHT_PORT", "9009"))
UPSTREAM: Final = os.environ.get(
"HERMES_CLASSIFIER_BROKER_UPSTREAM",
"http://ollama.ai.svc.cluster.local:11434",
@ -31,6 +46,7 @@ READ_TIMEOUT_SECONDS: Final = float(
os.environ.get("HERMES_CLASSIFIER_BROKER_READ_TIMEOUT", "60")
)
ALLOWED_PATHS: Final = {"/v1/chat/completions", "/v1/models"}
VOICE_PREFLIGHT_PATH: Final = "/voice/route-preflight"
def _bounded_text(value: str, limit: int) -> str:
@ -177,10 +193,28 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _empty(self, status: int) -> None:
self.send_response(status)
self.send_header("Content-Length", "0")
self.send_header("Cache-Control", "no-store")
self.end_headers()
def _text(self, status: int, body: str, content_type: str) -> None:
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(encoded)
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
self._json(200, {"ok": True, "upstream": "ollama"})
return
if self.path == "/metrics":
self._text(200, VOICE_PREFLIGHT.metrics(), "text/plain; version=0.0.4")
return
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": "not found"})
return
@ -190,28 +224,81 @@ class Handler(BaseHTTPRequestHandler):
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": "not found"})
return
authoritative = self.path == "/v1/chat/completions"
if authoritative:
# Mark priority as soon as the authoritative path arrives, before
# even validating or reading its local body, so an
# in-flight disposable decode is closed at authoritative arrival.
VOICE_PREFLIGHT.begin_authoritative()
try:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = -1
if length <= 0 or length > MAX_BODY_BYTES:
self._json(413, {"error": "request too large"})
return
try:
payload = json.loads(self.rfile.read(length))
if not isinstance(payload, dict):
raise ValueError("request must be a JSON object")
body = json.dumps(
compact_payload(payload), separators=(",", ":")
).encode("utf-8")
except (ValueError, TypeError, json.JSONDecodeError) as exc:
self._json(400, {"error": str(exc)})
return
print(
f"classifier-broker request_bytes={length} compacted_bytes={len(body)}",
flush=True,
)
if authoritative:
self._proxy(body)
return
self._proxy(body)
finally:
if authoritative:
VOICE_PREFLIGHT.end_authoritative()
def _voice_preflight(self) -> None:
"""Return one disposable local tier without starting a hosted turn."""
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = -1
if length <= 0 or length > MAX_BODY_BYTES:
if length <= 0 or length > VOICE_PREFLIGHT_MAX_BODY_BYTES:
self._json(413, {"error": "request too large"})
return
try:
payload = json.loads(self.rfile.read(length))
if not isinstance(payload, dict):
raise ValueError("request must be a JSON object")
body = json.dumps(
compact_payload(payload), separators=(",", ":")
).encode("utf-8")
turn_id, revision, transcript = validate_voice_preflight_request(payload)
except (ValueError, TypeError, json.JSONDecodeError) as exc:
self._json(400, {"error": str(exc)})
return
print(
f"classifier-broker request_bytes={length} compacted_bytes={len(body)}",
flush=True,
cancel = VOICE_PREFLIGHT.begin(turn_id)
if cancel is None:
self._empty(204)
return
try:
tier = VOICE_PREFLIGHT.stream(transcript, cancel)
finally:
VOICE_PREFLIGHT.end()
if not tier:
self._empty(204)
return
self._json(
200,
{
"turn_id": turn_id,
"revision": revision,
"tier": tier,
"target": f"atlas/auto/{tier}",
"advisory": True,
},
)
self._proxy(body)
def _proxy(self, body: bytes | None) -> None:
try:
@ -235,9 +322,37 @@ class Handler(BaseHTTPRequestHandler):
self._json(503, {"error": f"classifier unavailable: {exc}"})
class VoiceHandler(Handler):
"""Expose only advisory, health and telemetry routes to chat tenants."""
server_version = "HermesVoicePreflight/1"
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
self._json(200, {"ok": True, "upstream": "ollama"})
return
if self.path == "/metrics":
self._text(200, VOICE_PREFLIGHT.metrics(), "text/plain; version=0.0.4")
return
self._json(404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
if self.path == VOICE_PREFLIGHT_PATH:
self._voice_preflight()
return
self._json(404, {"error": "not found"})
def main() -> None:
voice_server = ThreadingHTTPServer((HOST, VOICE_PORT), VoiceHandler)
voice_thread = threading.Thread(target=voice_server.serve_forever, daemon=True)
voice_thread.start()
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.serve_forever()
try:
server.serve_forever()
finally:
voice_server.shutdown()
voice_server.server_close()
if __name__ == "__main__":

View File

@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""Bounded local route advice for provisional Hermes voice transcripts."""
from __future__ import annotations
import json
import os
import re
import threading
import time
from typing import Any, Final
import httpx
UPSTREAM: Final = os.environ.get(
"HERMES_CLASSIFIER_BROKER_UPSTREAM",
"http://ollama.ai.svc.cluster.local:11434",
).rstrip("/")
MODEL: Final = os.environ.get(
"HERMES_VOICE_PREFLIGHT_MODEL", "qwen2.5:14b-instruct-q4_0"
)
MAX_BODY_BYTES: Final = 2 * 1024
MAX_TEXT_CHARS: Final = 512
TIMEOUT_SECONDS: Final = max(
0.1, min(0.75, float(os.environ.get("HERMES_VOICE_PREFLIGHT_TIMEOUT", "0.7")))
)
TIERS: Final = {"fast", "balanced", "deep", "maximum"}
TURN_PATTERN: Final = re.compile(r"[A-Za-z0-9_.:-]{1,128}\Z")
SEEN_TTL_SECONDS: Final = 120.0
MAX_SEEN_TURNS: Final = 256
def validate_request(payload: dict[str, Any]) -> tuple[str, int, str]:
"""Validate one bounded provisional transcript without identity coercion."""
turn_id = payload.get("turn_id")
revision = payload.get("revision")
transcript = payload.get("transcript")
if not isinstance(turn_id, str) or not TURN_PATTERN.fullmatch(turn_id):
raise ValueError("invalid turn_id")
if isinstance(revision, bool) or not isinstance(revision, int):
raise ValueError("invalid revision")
if revision < 1 or revision > 1_000_000:
raise ValueError("invalid revision")
if not isinstance(transcript, str):
raise ValueError("invalid transcript")
transcript = " ".join(transcript.split())
if len(transcript) < 12 or len(transcript) > MAX_TEXT_CHARS:
raise ValueError("invalid transcript")
return turn_id, revision, transcript
def inference_payload(transcript: str) -> dict[str, Any]:
"""Build the tiny local-only classification request used during speech."""
return {
"model": MODEL,
"messages": [
{
"role": "system",
"content": (
"Classify this incomplete spoken request into one advisory effort "
"tier. Return JSON only as {\"tier\":\"fast|balanced|deep|maximum\"}. "
"Use fast for simple conversation or facts, balanced for ordinary "
"assistance, deep for multi-step work, and maximum only for explicit "
"high-stakes or unusually complex work. This is not authoritative."
),
},
{"role": "user", "content": transcript},
],
"temperature": 0,
"max_tokens": 16,
"stream": True,
"response_format": {"type": "json_object"},
}
class Coordinator:
"""Serialize disposable inference and give final classification priority."""
def __init__(self) -> None:
self.condition = threading.Condition()
self.authoritative = 0
self.active = False
self.cancel: threading.Event | None = None
self.client: Any = None
self.response: Any = None
self.seen: dict[str, float] = {}
self.stats = {
"admitted": 0,
"rejected": 0,
"success": 0,
"timeout": 0,
"cancelled": 0,
"failure": 0,
"preempted": 0,
}
self.duration_count = 0
self.duration_sum = 0.0
def _prune(self, now: float) -> None:
self.seen = {
turn: created
for turn, created in self.seen.items()
if now - created <= SEEN_TTL_SECONDS
}
if len(self.seen) > MAX_SEEN_TURNS:
self.seen = dict(sorted(self.seen.items(), key=lambda item: item[1])[-MAX_SEEN_TURNS:])
def begin_authoritative(self) -> None:
"""Cancel advisory I/O as soon as a real classifier request arrives."""
with self.condition:
self.authoritative += 1
if self.cancel is not None:
self.cancel.set()
self.stats["preempted"] += 1
resources = (self.response, self.client)
for resource in resources:
if resource is not None:
try:
resource.close()
except Exception:
pass
def end_authoritative(self) -> None:
"""Release the real-classifier priority marker."""
with self.condition:
self.authoritative = max(0, self.authoritative - 1)
self.condition.notify_all()
def begin(self, turn_id: str) -> threading.Event | None:
"""Admit at most one advisory globally and once per browser turn."""
now = time.monotonic()
with self.condition:
self._prune(now)
if self.active or self.authoritative or turn_id in self.seen:
self.stats["rejected"] += 1
return None
self.seen[turn_id] = now
self.active = True
self.cancel = threading.Event()
self.stats["admitted"] += 1
return self.cancel
def register(
self, cancel: threading.Event, *, client: Any = None, response: Any = None
) -> bool:
"""Publish cancellable resources unless authority already preempted them."""
with self.condition:
if (
not self.active
or self.cancel is not cancel
or cancel.is_set()
or self.authoritative
):
cancel.set()
return False
if client is not None:
self.client = client
if response is not None:
self.response = response
return True
def end(self) -> None:
"""Release the disposable local lane after its bounded request."""
with self.condition:
self.active = False
self.cancel = None
self.client = None
self.response = None
self.condition.notify_all()
def _record(self, outcome: str, started: float) -> None:
"""Record bounded low-cardinality timing without transcript or turn labels."""
elapsed = max(0.0, time.monotonic() - started)
with self.condition:
self.stats[outcome] += 1
self.duration_count += 1
self.duration_sum += elapsed
print(
f"voice-route-preflight outcome={outcome} duration_ms={elapsed * 1000:.1f}",
flush=True,
)
def metrics(self) -> str:
"""Render low-cardinality Prometheus telemetry with no user content."""
with self.condition:
stats = dict(self.stats)
duration_count = self.duration_count
duration_sum = self.duration_sum
authority = self.authoritative
active = int(self.active)
lines = [
"# HELP hermes_voice_route_preflight_total Local voice route preflight outcomes.",
"# TYPE hermes_voice_route_preflight_total counter",
]
lines.extend(
f'hermes_voice_route_preflight_total{{outcome="{name}"}} {value}'
for name, value in sorted(stats.items())
)
lines.extend(
[
"# TYPE hermes_voice_route_preflight_duration_seconds summary",
f"hermes_voice_route_preflight_duration_seconds_count {duration_count}",
f"hermes_voice_route_preflight_duration_seconds_sum {duration_sum:.6f}",
"# TYPE hermes_voice_route_preflight_active gauge",
f"hermes_voice_route_preflight_active {active}",
"# TYPE hermes_classifier_authoritative_active gauge",
f"hermes_classifier_authoritative_active {authority}",
]
)
return "\n".join(lines) + "\n"
def stream(self, transcript: str, cancel: threading.Event) -> str:
"""Run one hard-bounded cancellable local Qwen advisory decode."""
started = time.monotonic()
outcome = "failure"
deadline = time.monotonic() + TIMEOUT_SECONDS
timeout = httpx.Timeout(
min(0.35, TIMEOUT_SECONDS),
connect=min(0.25, TIMEOUT_SECONDS),
read=min(0.35, TIMEOUT_SECONDS),
write=min(0.25, TIMEOUT_SECONDS),
pool=min(0.1, TIMEOUT_SECONDS),
)
client = httpx.Client(timeout=timeout)
if not self.register(cancel, client=client):
client.close()
self._record("cancelled", started)
return ""
response = None
def expire() -> None:
cancel.set()
for resource in (response, client):
if resource is not None:
try:
resource.close()
except Exception:
pass
timer = threading.Timer(max(0.01, deadline - time.monotonic()), expire)
timer.daemon = True
timer.start()
content = ""
try:
request = client.build_request(
"POST",
f"{UPSTREAM}/v1/chat/completions",
headers={"Content-Type": "application/json"},
json=inference_payload(transcript),
)
response = client.send(request, stream=True)
if not self.register(cancel, response=response):
outcome = "cancelled"
response.close()
return ""
response.raise_for_status()
for line in response.iter_lines():
if cancel.is_set() or time.monotonic() >= deadline:
break
if not line.startswith("data:"):
continue
event = line[5:].strip()
if event == "[DONE]":
break
try:
delta = (json.loads(event)["choices"][0].get("delta") or {}).get(
"content", ""
)
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
continue
if isinstance(delta, str):
content += delta
if len(content) > 128:
cancel.set()
break
if cancel.is_set() or time.monotonic() >= deadline:
outcome = "timeout" if time.monotonic() >= deadline else "cancelled"
return ""
decoded = json.loads(content)
tier = decoded.get("tier") if isinstance(decoded, dict) else None
if tier in TIERS:
outcome = "success"
return tier
return ""
except Exception:
outcome = "cancelled" if cancel.is_set() else "failure"
return ""
finally:
cancel.set()
timer.cancel()
if response is not None:
try:
response.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
self._record(outcome, started)
COORDINATOR = Coordinator()

View File

@ -22,7 +22,7 @@ spec:
labels:
app: hermes-switchyard
annotations:
ai.bstein.dev/config-rev: "20260823-provider-neutral-pools"
ai.bstein.dev/config-rev: "20260823-voice-route-preflight-v1"
prometheus.io/scrape: "true"
prometheus.io/port: "9005"
prometheus.io/path: /metrics
@ -182,11 +182,16 @@ spec:
- name: classifier
containerPort: 9008
protocol: TCP
- name: voice-preflight
containerPort: 9009
protocol: TCP
env:
- name: HERMES_CLASSIFIER_BROKER_UPSTREAM
value: http://ollama.ai.svc.cluster.local:11434
- name: HERMES_CLASSIFIER_BROKER_READ_TIMEOUT
value: "60"
- name: HERMES_VOICE_PREFLIGHT_MODEL
value: qwen2.5:14b-instruct-q4_0
readinessProbe:
httpGet:
path: /health

View File

@ -4,6 +4,10 @@ kind: Service
metadata:
name: hermes-switchyard
namespace: hermes
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9009"
prometheus.io/path: /metrics
labels:
app: hermes-switchyard
spec:
@ -15,3 +19,7 @@ spec:
port: 9005
targetPort: http
protocol: TCP
- name: voice-preflight
port: 9009
targetPort: voice-preflight
protocol: TCP

View File

@ -654,11 +654,11 @@ data:
}
pipelineJob('hermes-agent-image') {
disabled(false)
description('Bounded daemonless Kaniko release for the latest atlas/titan-iac main containing a reviewed commit. Archives exact evidence, then publishes an immutable tag for Flux deployment.')
description('Bounded daemonless Kaniko release for one exact reviewed atlas/titan-iac revision already contained by main. Archives exact evidence, then publishes an immutable tag for Flux deployment.')
authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))
parameters {
booleanParam('PUBLISH_IMAGE', false, 'Publish the reviewed Hermes agent image.')
stringParam('EXPECTED_SOURCE_REVISION', '', 'Full reviewed commit that must be contained by atlas/titan-iac main.')
stringParam('EXPECTED_SOURCE_REVISION', '', 'Full reviewed commit already contained by atlas/titan-iac main.')
stringParam('CONFIRM_PUBLISH', '', 'Exact confirmation: PUBLISH HERMES AGENT')
}
definition {
@ -678,7 +678,7 @@ data:
}
pipelineJob('hermes-webui-image') {
disabled(false)
description('Bounded daemonless Kaniko release for the latest atlas/titan-iac main containing a reviewed commit. Archives exact evidence, then publishes an immutable tag for Flux deployment.')
description('Bounded daemonless Kaniko release for one exact reviewed atlas/titan-iac revision already contained by main. Archives exact evidence, then publishes an immutable tag for Flux deployment.')
authenticationToken(System.getenv('HERMES_AGENT_IMAGE_BUILD_TOKEN'))
parameters {
booleanParam('PUBLISH_IMAGE', false, 'Publish the reviewed Hermes WebUI image.')

View File

@ -7,6 +7,8 @@ Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
- Version: `0.52.181`
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
- Full upstream `api/routes.py` SHA-256: `a09c3a236a323b0d1ad22409f6576fa21a0f3ca413ad8eeec06abb87067dc2ab`
- Full upstream `static/panels.js` SHA-256: `276e5dfc2cac6eee4a1820e4fab9c4da52743efed4a1b1e9d58d6dd15f6fd5c9`
The fixture stays intentionally narrow, but tests execute the shipped patchers
against it; large inline artwork bodies are reduced while their exact unique

View File

@ -44,6 +44,17 @@ def _handle_tts(handler, data, text, rate_str, engine):
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
if parsed.path == "/api/settings":
settings = {}
# Inject the running version so the UI badge stays in sync with git tags
# without any manual release step.
try:
from api.updates import AGENT_VERSION, WEBUI_VERSION
settings["webui_version"] = WEBUI_VERSION
settings["agent_version"] = AGENT_VERSION
except Exception:
pass
return j(handler, settings)
return False

View File

@ -5,6 +5,67 @@ const _SETTINGS_SPEECH_STORAGE_KEYS={
};
let _settingsSpeechChangedKeys=new Set();
function _normalizeWebUIVersion(value){
if(!value) return '';
const s=String(value).trim();
if(!s) return '';
const lower=s.toLowerCase();
if(lower==='__webui_version__'||lower==='not detected'||lower==='unknown') return '';
return s;
}
function _currentWebUIBundleVersion(){
try{
const raw=window.__HERMES_WEBUI_BUNDLE_VERSION__;
if(!raw) return '';
let s=String(raw);
try{ s=decodeURIComponent(s.replace(/\+/g,' ')); }catch(_){}
return _normalizeWebUIVersion(s);
}catch(_){ return ''; }
}
function _showStaleWebUIClientBanner(clientVersion,serverVersion){
const banner=document.getElementById('staleClientBanner');
if(!banner) return;
const msg=document.getElementById('staleClientMessage');
const versions=document.getElementById('staleClientVersions');
if(msg) msg.textContent='This tab is running a different WebUI version. Hard refresh to restore full functionality.';
if(versions) versions.textContent='Running: '+clientVersion+' → Server: '+serverVersion;
banner.style.display='flex';
}
function checkWebUIVersionSkew(settings){
try{
if(!settings) return;
const client=_currentWebUIBundleVersion();
const server=_normalizeWebUIVersion(settings.webui_version);
if(!client||!server) return;
if(client===server) return;
_showStaleWebUIClientBanner(client,server);
}catch(_){}
}
window.checkWebUIVersionSkew=checkWebUIVersionSkew;
function _startWebUIVersionSkewMonitor(){
let _pollTimer=null;
function _isBannerVisible(){
const banner=document.getElementById('staleClientBanner');
return !!(banner&&banner.style.display==='flex');
}
function _check(){
if(_isBannerVisible()) return;
Promise.resolve().then(function(){ return api('/api/settings'); }).then(function(s){ checkWebUIVersionSkew(s); }).catch(function(){});
}
function _startPoll(){
if(_pollTimer||document.hidden) return;
_pollTimer=setInterval(function(){
if(document.hidden){ clearInterval(_pollTimer); _pollTimer=null; return; }
if(_isBannerVisible()){ clearInterval(_pollTimer); _pollTimer=null; return; }
_check();
},60000);
}
}
function _speechPreferencesPayloadFromUi(){
const payload={};
const ttsVoiceSel=$('settingsTtsVoice');

View File

@ -18,6 +18,7 @@
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/voice_route_preflight.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",
@ -109,6 +110,7 @@
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/voice_route_preflight.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",
@ -319,6 +321,7 @@
"minimum_percent": 95.0,
"minimum_branch_percent": 95.0,
"branch_tracked_files": [
"services/hermes/scripts/voice_route_preflight.py",
"scripts/ops/hermes_handoff_acceptance.py",
"scripts/ops/hermes_handoff_arming.py",
"scripts/ops/hermes_handoff_catalog.py",
@ -340,6 +343,7 @@
"testing/quality_handoff_mutation.py"
],
"tracked_files": [
"services/hermes/scripts/voice_route_preflight.py",
"ci/scripts/hermes_image_release.py",
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",

View File

@ -492,6 +492,71 @@ scenarios.adaptive_chunks_are_sentence_gated = async () => {
return { partial, complete };
};
scenarios.spoken_urls_are_skipped_without_damaging_text = async () => {
const harness = makeHarness();
await harness.flush();
const clean = harness.context._atlasStripHttpUrlsForSpeech;
return {
sentence: clean('Read this https://example.com/docs. Then continue.'),
wrapped: clean('Open (https://example.com/a_(b)). Next.'),
punctuated: clean('Try https://example.com/a?q=1, or https://example.org/x!'),
domains: clean('Keep example.com and sub.example.org exactly as written.'),
prose: clean('No links here; keep this sentence exactly as written.'),
};
};
scenarios.spoken_urls_are_elided_before_all_voice_routes = async () => {
const results = [];
for (const language of ['en', 'ru', 'es']) {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, {
transcript: 'Read the answer.',
language,
reply: 'Keep example.com, but skip https://private.example/path. Done.',
});
results.push({ language, tts: harness.ttsRequests });
}
return { results };
};
scenarios.canonical_pcm_fallback_builds_a_valid_wav = async () => {
const harness = makeHarness();
await harness.flush();
const pcm = new Uint8Array([0x00, 0x00, 0xff, 0x7f, 0x00, 0x80]);
const blob = harness.context._atlasPcm16WavBlob([pcm.buffer], 16000);
const bytes = new Uint8Array(blob.parts[0]);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const text = (start, length) => String.fromCharCode(...bytes.slice(start, start + length));
return {
type: blob.type,
size: bytes.length,
riff: text(0, 4),
wave: text(8, 4),
format: view.getUint16(20, true),
channels: view.getUint16(22, true),
rate: view.getUint32(24, true),
bits: view.getUint16(34, true),
data: view.getUint32(40, true),
pcm: Array.from(bytes.slice(44)),
};
};
scenarios.applied_aec_settings_are_fail_safe = async () => {
const harness = makeHarness();
await harness.flush();
const check = harness.context._atlasCaptureAecIsUsable;
const stream = (value) => ({
getAudioTracks: () => [{ getSettings: () => ({ echoCancellation: value }) }],
});
return {
applied: check(stream(true)),
rejected: check(stream(false)),
unknown: check({ getAudioTracks: () => [{ getSettings: () => ({}) }] }),
unsupported: check({}),
};
};
scenarios.first_sentence_speaks_before_stream_completion = async () => {
const harness = makeHarness();
await startVoiceMode(harness);

View File

@ -101,6 +101,8 @@ def test_pipeline_requires_reviewed_main_and_runtime_credentials() -> None:
assert "git rev-parse origin/main" in source
assert "git fetch --no-tags origin main" not in source
assert 'git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}"' in source
assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in source
assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in source
assert "credentialsId: 'harbor-robot'" in source
assert "set +x" in source
assert "umask 077" in source

View File

@ -8,6 +8,7 @@ import struct
import sys
import threading
import time
import wave
from pathlib import Path
from types import SimpleNamespace
@ -63,6 +64,25 @@ class _Connection:
self.condition.wait(deadline - time.monotonic())
raise AssertionError(f"no {message_type} response: {self.messages}")
def wait_for_revision(self, revision: int, timeout: float = 2.0) -> dict:
"""Wait for one particular rolling partial rather than an older one."""
deadline = time.monotonic() + timeout
with self.condition:
while time.monotonic() < deadline:
match = next(
(
message
for message in self.messages
if message.get("type") == "partial"
and message.get("revision") == revision
),
None,
)
if match:
return match
self.condition.wait(deadline - time.monotonic())
raise AssertionError(f"no rolling revision {revision}: {self.messages}")
class _Model:
"""Return a deterministic Whisper-shaped result and retain decode options."""
@ -85,6 +105,46 @@ class _Model:
}
class _SequencedModel(_Model):
"""Return evolving text and record how much PCM each decode consumed."""
def __init__(self, texts: list[str]) -> None:
super().__init__()
self.texts = texts
self.frame_counts: list[int] = []
def transcribe(self, path: str, **options: object) -> dict:
with wave.open(path, "rb") as wav_file:
self.frame_counts.append(wav_file.getnframes())
text = self.texts[min(len(self.calls), len(self.texts) - 1)]
self.calls.append((path, options))
return {
"text": f" {text}",
"language": "en",
"segments": [
{
"text": f" {text}",
"no_speech_prob": 0.01,
"avg_logprob": -0.1,
}
],
}
class _BlockingModel(_Model):
"""Hold inference so epoch/cancel behavior can be tested deterministically."""
def __init__(self) -> None:
super().__init__()
self.entered = threading.Event()
self.release = threading.Event()
def transcribe(self, path: str, **options: object) -> dict:
self.entered.set()
assert self.release.wait(2.0)
return super().transcribe(path, **options)
def _start(session) -> None:
session.start(
{
@ -105,6 +165,22 @@ def _silence(samples: int = 12_000) -> bytes:
return bytes(samples * 2)
def _configure_fast_rolling(module, monkeypatch) -> None:
"""Make rolling thresholds deterministic without adding test sleeps."""
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
monkeypatch.setattr(module, "ROLLING_INTERVAL_MS", 100)
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
def _wait_rolling_idle(session, timeout: float = 1.0) -> None:
"""Wait until an inference worker has applied or discarded its result."""
deadline = time.monotonic() + timeout
with session._lock:
while session._rolling_inflight and time.monotonic() < deadline:
session._lock.wait(deadline - time.monotonic())
assert not session._rolling_inflight
def test_websocket_handshake_matches_rfc_example(monkeypatch):
module = _load_server(monkeypatch)
@ -158,6 +234,161 @@ def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch):
assert model.calls[0][1]["condition_on_previous_text"] is False
def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
module = _load_server(monkeypatch)
_configure_fast_rolling(module, monkeypatch)
connection = _Connection()
model = _SequencedModel(["please open", "please open my calendar"])
session = module.StreamingTranscription(connection, model)
_start(session)
session.append(_speech())
first = connection.wait_for_revision(1)
assert first == {
"type": "partial",
"turn_id": "turn-10",
"transcript": "please open",
"stable_transcript": "",
"language": "en",
"speculative": True,
"rolling": True,
"revision": 1,
"epoch": 0,
"window_start_ms": 0,
}
# Audio progress is required in addition to wall-clock rate limiting.
session._rolling_last_started_at = 0.0
session.append(_speech())
second = connection.wait_for_revision(2)
assert second["transcript"] == "please open my calendar"
assert second["stable_transcript"] == "please open"
assert len(model.calls) == 2
def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch):
module = _load_server(monkeypatch)
_configure_fast_rolling(module, monkeypatch)
monkeypatch.setattr(module, "ROLLING_WINDOW_MS", 300)
connection = _Connection()
model = _SequencedModel(["partial", "complete request"])
session = module.StreamingTranscription(connection, model)
_start(session)
utterance = _speech(samples=16_000)
session.append(utterance)
partial = connection.wait_for_revision(1)
assert partial["window_start_ms"] == 700
assert model.frame_counts == [4_800]
session.commit()
assert connection.wait_for("final")["transcript"] == "complete request"
assert model.frame_counts == [4_800, 16_000]
def test_cancel_drops_inflight_rolling_result(monkeypatch):
module = _load_server(monkeypatch)
_configure_fast_rolling(module, monkeypatch)
connection = _Connection()
model = _BlockingModel()
session = module.StreamingTranscription(connection, model)
_start(session)
session.append(_speech())
assert model.entered.wait(1.0)
session.cancel()
model.release.set()
_wait_rolling_idle(session)
assert connection.messages == []
def test_resume_epoch_drops_old_rolling_result(monkeypatch):
module = _load_server(monkeypatch)
_configure_fast_rolling(module, monkeypatch)
connection = _Connection()
model = _BlockingModel()
session = module.StreamingTranscription(connection, model)
_start(session)
session.append(_speech())
assert model.entered.wait(1.0)
session.resume()
model.release.set()
_wait_rolling_idle(session)
assert connection.messages == []
assert session._epoch == 1
def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
module = _load_server(monkeypatch)
_configure_fast_rolling(module, monkeypatch)
connection = _Connection()
model = _Model()
session = module.StreamingTranscription(connection, model)
_start(session)
release = threading.Event()
entered = threading.Event()
def occupy_gpu():
module.INFERENCE_GATE.try_background(
lambda: (entered.set(), release.wait(2.0))
)
worker = threading.Thread(target=occupy_gpu)
worker.start()
assert entered.wait(1.0)
try:
session.append(_speech())
_wait_rolling_idle(session)
assert model.calls == []
assert connection.messages == []
finally:
release.set()
worker.join(timeout=1.0)
# A later audio interval gets another opportunity once the final-priority
# GPU lock is free; the skipped snapshot did not leave a queued worker.
session._rolling_last_started_at = 0.0
session.append(_speech())
assert connection.wait_for_revision(1)["transcript"] == "hello"
assert len(model.calls) == 1
def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
module = _load_server(monkeypatch)
first_entered = threading.Event()
release_first = threading.Event()
final_ran = threading.Event()
first = threading.Thread(
target=lambda: module.INFERENCE_GATE.try_background(
lambda: (first_entered.set(), release_first.wait(2.0))
)
)
first.start()
assert first_entered.wait(1.0)
final = threading.Thread(
target=lambda: module.INFERENCE_GATE.run_final(final_ran.set)
)
final.start()
deadline = time.monotonic() + 1.0
while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline:
time.sleep(0.005)
assert module.INFERENCE_GATE._final_waiters == 1
assert module.INFERENCE_GATE.try_background(lambda: "ran") is None
release_first.set()
first.join(timeout=1.0)
final.join(timeout=1.0)
assert final_ran.is_set()
telemetry = module.INFERENCE_GATE.snapshot()
assert telemetry["background_skips"] == 1
assert telemetry["last_final_wait_ms"] >= 0
assert telemetry["last_final_decode_ms"] >= 0
def test_resume_invalidates_speculative_snapshot(monkeypatch):
module = _load_server(monkeypatch)
connection = _Connection()
@ -238,4 +469,5 @@ def test_stream_health_contract_is_declared_in_source():
assert '"format": "pcm_s16le"' in source
assert '"server_vad": True' in source
assert '"speculative": True' in source
assert '"inference": INFERENCE_GATE.snapshot()' in source
assert "MAX_STREAM_AUDIO_BYTES" in source

View File

@ -0,0 +1,65 @@
"""Release contracts for localized, interruptible thinking voice cues."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
VOICE = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
def _source() -> str:
return VOICE.read_text(encoding="utf-8")
def test_fast_answers_suppress_cues_and_long_waits_are_bounded():
source = _source()
assert "const THINKING_CUE_FIRST_MS=1900" in source
assert "const THINKING_CUE_INTERVAL_MS=6500" in source
assert "cue.issued>=cue.pool.length" in source
assert "scheduleNextThinkingCue(cue,THINKING_CUE_FIRST_MS)" in source
assert "scheduleNextThinkingCue(cue,THINKING_CUE_INTERVAL_MS)" in source
assert "cancelThinkingCues();\n const turn=ensureSpeechTurn(token)" in source
def test_cues_are_localized_deterministic_and_non_repeating():
source = _source()
assert "{id:'thinking',text:\"I'm thinking.\"}" in source
assert "{id:'let_me_think',text:'Дайте подумать.'}" in source
assert "{id:'still_working',text:'Sigo pensando en eso.'}" in source
assert "offset:cuePoolOffset(turnId,pool.length)" in source
assert "cue.pool[(cue.offset+cue.issued)%cue.pool.length]" in source
assert "cue.issued+=1" in source
assert "const localized=normalizeSttLanguage(language)||'en'" in source
def test_cues_are_turn_owned_audio_only_and_abortable():
source = _source()
cue_region = source.split("async function issueThinkingCue(cue){", 1)[1].split(
"function scheduleThinkingCues", 1
)[0]
assert "cue.token===generation" in source
assert "state==='thinking'" in source
assert "cue.turnId+':thinking-cue:'" in cue_region
assert "request.cue_id=entry.id" in cue_region
assert "fetch(TTS_STREAM_URL" in cue_region
assert "fetch('/api/tts'" not in cue_region
assert "atlas-pcm-playback" in cue_region
assert "signal:controller.signal" in cue_region
assert "cue.controller.abort()" in source
assert "cancelThinkingCues();\n cancelSpeechTurn();" in source
assert "composer.value" not in cue_region
assert "window.send" not in cue_region
def test_thinking_barge_in_arms_quickly_but_guards_playback_echo():
source = _source()
assert "let speechArmAt=Date.now()+100" in source
assert "speechArmAt=now+(state==='thinking'?150:450)" in source
assert "playbackActive&&!playbackWasActive" in source
assert "const BARGE_TRIGGER_FRAMES=4" in source
assert "},50);" in source
assert "lookback:monitor.lookback" in source

View File

@ -5,7 +5,9 @@ from __future__ import annotations
import importlib.util
import io
import json
import struct
import sys
import wave
from types import SimpleNamespace
import pytest
@ -19,6 +21,7 @@ CLAUDE = "es_MX-claude-high"
def _load_tts_server(monkeypatch):
server_path = ROOT / "dockerfiles" / "hermes-jetson-tts-server.py"
monkeypatch.syspath_prepend(str(server_path.parent))
spec = importlib.util.spec_from_file_location("hermes_jetson_tts_server", server_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
@ -239,6 +242,72 @@ def test_turn_ids_are_safe_to_echo_in_response_headers(tts, value, expected):
assert tts.normalize_turn_id(value) == expected
@pytest.mark.parametrize(
"text,expected",
[
("A short clause,", 90),
("A continued thought:", 110),
("Is this natural?", 170),
("This is natural.", 190),
("Thinking...", 220),
("First paragraph.\n\n", 280),
("A provisional phrase", 70),
('She said, "done."', 190),
],
)
def test_trailing_pause_budget_tracks_punctuation(tts, text, expected):
assert tts.trailing_pause_ms(text) == expected
def test_sentence_pause_targets_keep_paragraph_and_final_clause_cadence(tts):
assert tts.sentence_pause_targets("First.\n\nSecond? A tail,") == [280, 170, 90]
def _pcm(*runs: tuple[int, int]) -> bytes:
return b"".join(struct.pack(f"<{count}h", *([value] * count)) for value, count in runs)
def test_pcm_tail_trim_preserves_voice_and_keeps_period_pause(tts):
# A deliberately low-amplitude final phoneme remains above the very
# conservative silence threshold and must remain intact.
pcm = _pcm((1200, 50), (40, 50), (0, 600))
normalized = tts.trim_trailing_pcm_silence(pcm, sample_rate=1000, pause_ms=190)
assert bytes(normalized[:200]) == pcm[:200]
assert len(normalized) == (100 + 190) * 2
assert bytes(normalized[-190 * 2 :]) == b"\x00" * (190 * 2)
def test_pcm_tail_trim_leaves_short_and_all_silent_audio_unchanged(tts):
near_target = _pcm((900, 100), (0, 205))
all_silent = _pcm((0, 800))
assert bytes(tts.trim_trailing_pcm_silence(near_target, 1000, 190)) == near_target
assert bytes(tts.trim_trailing_pcm_silence(all_silent, 1000, 190)) == all_silent
def test_pcm_tail_trim_rejects_malformed_or_unknown_format_without_mutation(tts):
malformed = b"\x01\x02\x03"
assert bytes(tts.trim_trailing_pcm_silence(malformed, 22_050, 190)) == malformed
assert bytes(tts.trim_trailing_pcm_silence(b"\x01\x00", 0, 190)) == b"\x01\x00"
def test_compatibility_wav_uses_the_same_period_pause_budget(tts):
source = io.BytesIO()
with wave.open(source, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(1000)
wav_file.writeframes(_pcm((1000, 100), (0, 600)))
normalized = tts.normalize_wav_trailing_pause(source.getvalue(), "Finished.")
with wave.open(io.BytesIO(normalized), "rb") as wav_file:
assert wav_file.getparams().nchannels == 1
assert wav_file.getparams().sampwidth == 2
assert wav_file.getparams().framerate == 1000
assert wav_file.getnframes() == 100 + 190
def _decode_chunked_response(data: bytes) -> bytes:
decoded = bytearray()
cursor = 0
@ -328,6 +397,154 @@ def test_stream_endpoint_returns_progressive_pcm_and_preserves_voice_policy(tts)
assert _decode_chunked_response(handler.wfile.getvalue()) == first_pcm + second_pcm
def test_cached_thinking_cue_is_lock_free_and_ignores_client_text(tts):
cached = SimpleNamespace(
cue_id="thinking",
language="ru",
voice_name=IRINA,
sample_rate=22_050,
pcm=b"\x01\x00\x02\x00",
)
class _CachedHandler(tts.SpeechHandler):
def __init__(self):
payload = json.dumps(
{
"text": "attacker-controlled text is ignored",
"language": "ru",
"cue_id": "thinking",
"turn_id": "turn-9:thinking-cue:1",
}
).encode()
self.path = "/v1/audio/speech/stream"
self.headers = {"Content-Length": str(len(payload))}
self.rfile = io.BytesIO(payload)
self.wfile = io.BytesIO()
self.status = None
self.response_headers = {}
self.close_connection = False
self.server = SimpleNamespace(
voices={}, default_voice_name=AMY, cue_cache={("ru", "thinking"): cached}
)
def send_response(self, status, message=None):
self.status = status
def send_header(self, name, value):
self.response_headers[name] = value
def end_headers(self):
return None
handler = _CachedHandler()
handler.do_POST()
assert handler.status == 200
assert handler.wfile.getvalue() == cached.pcm
assert handler.response_headers["X-TTS-Voice"] == IRINA
assert handler.response_headers["X-TTS-Cue-ID"] == "thinking"
assert handler.response_headers["X-TTS-Cache"] == "HIT"
assert handler.response_headers["X-TTS-Turn-ID"] == "turn-9:thinking-cue:1"
def test_unknown_cached_thinking_cue_fails_closed(tts):
with pytest.raises(ValueError, match="unknown thinking cue"):
tts.resolve_cached_cue(
{"language": "en", "cue_id": "invented"},
{},
)
def test_thinking_cue_cache_precomputes_all_localized_voice_pairs(tts):
class _CueVoice:
config = SimpleNamespace(sample_rate=22_050)
def synthesize(self, text, syn_config):
assert text
assert syn_config.length_scale == 1.0
yield SimpleNamespace(
sample_rate=22_050,
sample_width=2,
sample_channels=1,
audio_int16_bytes=b"\x01\x00\x02\x00",
)
voices = {name: _CueVoice() for name in (AMY, IRINA, CLAUDE)}
cache = tts.build_cue_cache(
voices,
tts.SynthesisConfig,
lambda pcm, _rate, _pause: memoryview(pcm),
lambda _text: 190,
)
assert len(cache) == 12
assert {key[0] for key in cache} == {"en", "ru", "es"}
assert {cue.voice_name for (language, _), cue in cache.items() if language == "en"} == {AMY}
assert {cue.voice_name for (language, _), cue in cache.items() if language == "ru"} == {IRINA}
assert {cue.voice_name for (language, _), cue in cache.items() if language == "es"} == {CLAUDE}
assert all(cue.pcm and len(cue.pcm) % 2 == 0 for cue in cache.values())
def test_thinking_cue_cache_fails_startup_on_missing_or_malformed_audio(tts):
class _EmptyVoice:
config = SimpleNamespace(sample_rate=22_050)
def synthesize(self, _text, _syn_config):
return iter(())
voices = {name: _EmptyVoice() for name in (AMY, IRINA, CLAUDE)}
with pytest.raises(RuntimeError, match="returned no audio"):
tts.build_cue_cache(
voices,
tts.SynthesisConfig,
lambda pcm, _rate, _pause: memoryview(pcm),
lambda _text: 190,
)
def test_stream_endpoint_trims_each_sentence_with_its_own_pause_budget(tts):
sentence_pcm = _pcm((1000, 100), (0, 600))
class _StreamingVoice:
config = SimpleNamespace(sample_rate=1000)
def synthesize(self, text, syn_config):
assert text == "First. Final clause,"
for _ in range(2):
yield SimpleNamespace(
sample_rate=1000,
sample_width=2,
sample_channels=1,
audio_int16_bytes=sentence_pcm,
)
class _StreamingHandler(tts.SpeechHandler):
def __init__(self):
self.wfile = io.BytesIO()
self.response_headers = {}
self.close_connection = False
def send_response(self, status, message=None):
return None
def send_header(self, name, value):
self.response_headers[name] = value
def end_headers(self):
return None
handler = _StreamingHandler()
handler._stream_pcm("First. Final clause,", 1.0, AMY, _StreamingVoice(), "turn-2")
decoded = _decode_chunked_response(handler.wfile.getvalue())
# 100 ms of voiced audio plus 190 ms after the period and 90 ms after the
# final clause. The audio itself remains byte-for-byte identical.
assert len(decoded) == ((100 + 190) + (100 + 90)) * 2
assert decoded[: 100 * 2] == sentence_pcm[: 100 * 2]
second_start = (100 + 190) * 2
assert decoded[second_start : second_start + 100 * 2] == sentence_pcm[: 100 * 2]
def test_stream_disconnect_stops_before_synthesizing_another_sentence(tts):
generated: list[int] = []

View File

@ -0,0 +1,120 @@
"""Contracts for low-latency, interruptible Hermes hands-free playback."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
VOICE = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
WORKLET = ROOT / "dockerfiles/hermes-webui-atlas-voice-worklet.js"
def test_barge_in_keeps_aec_capture_and_requires_sustained_speech():
source = VOICE.read_text(encoding="utf-8")
assert "echoCancellation:true" in source
assert "noiseSuppression:true" in source
assert "autoGainControl:true" in source
assert "const BARGE_DUCK_FRAMES=2" in source
assert "const BARGE_TRIGGER_FRAMES=4" in source
assert "let speechArmAt=Date.now()+100" in source
assert "speechArmAt=now+(state==='thinking'?150:450)" in source
assert "speechArmAt=now+100" in source
assert "playbackActive&&!playbackWasActive" in source
assert "const threshold=Math.max(0.05,(noiseFloor*3)+0.008)" in source
assert "setPlaybackDucked(true)" in source
assert "setPlaybackDucked(false)" in source
assert "settings.echoCancellation!==false" in source
assert "const automaticBargeAllowed=!playbackActive||monitor.aecUsable" in source
def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback():
source = VOICE.read_text(encoding="utf-8")
assert "api/chat/cancel?stream_id=" in source
assert "controller.abort();},1800" in source
assert "const deadline=Date.now()+10000" in source
assert "stopResponseObserver();" in source
assert "cancelSpeechTurn();" in source
assert "stopPlayback();" in source
assert "bargeCancelPromise=cancelActiveModelTurn()" in source
assert "lookback:monitor.lookback" in source
assert "heardSpeech:true" in source
assert "reusedCapture.lookback.forEach" in source
assert "const bytes=new Uint8Array(16)" in source
assert "window.crypto.getRandomValues(bytes)" in source
assert "captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)" in source
def test_streamed_chunks_share_one_audio_timeline_until_final_drain():
source = VOICE.read_text(encoding="utf-8")
worklet = WORKLET.read_text(encoding="utf-8")
assert "if(session.node)" in source
assert "return session;" in source
assert "session.node.port.postMessage({type:'push'" in source
assert "session.node.port.postMessage({type:'end'})" in source
assert "await drainPcmPlayback(session,turn.token)" in source
assert "node.connect(gain)" in source
assert "gain.connect(context.destination)" in source
assert "this.ended&&!this.queue.length" in worklet
assert "this.port.postMessage({type:'drained'})" in worklet
def test_barge_cancel_settles_before_new_turn_baseline_and_observer():
source = VOICE.read_text(encoding="utf-8")
region = source.split("async function sendTranscript", 1)[1].split(
"function audioExtension", 1
)[0]
assert region.index("await settleBargeCancellation(token)") < region.index(
"rememberAssistantBaseline()"
)
assert region.index("rememberAssistantBaseline()") < region.index("window.send()")
assert region.index("window.send()") < region.index("startResponseObserver(token)")
assert "suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'" in source
def test_barge_handoff_never_uses_a_truncated_container_fallback():
source = VOICE.read_text(encoding="utf-8")
assert "monitor.handoff=true" in source
assert "if(!monitor.handoff) trimBargeLookback(monitor)" in source
assert "handoffMonitor:monitor" in source
assert "reusedCapture.lookback.forEach" in source
assert "requireStreamingLookback:true" in source
assert "allowContainerFallback===false" in source
assert "Please repeat your interruption" in source
def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned():
source = VOICE.read_text(encoding="utf-8")
partial = source.split("payload.type==='partial'", 1)[1].split(
"payload.type==='final'", 1
)[0]
fallback = source.split("async function fetchSpeech", 1)[1].split(
"function cuePoolOffset", 1
)[0]
assert "payload.stable_transcript" in partial
assert "label.textContent='Listening · '+preview" in partial
assert "composer.value" not in partial
assert "window.send" not in partial
assert "signal:controller.signal" in fallback
assert "token!==generation" in fallback
assert "session.turnId!==turnId" in fallback
assert "closePcmBeforeBlob" in source
def test_stt_client_queue_and_canonical_archive_are_strictly_bounded():
source = VOICE.read_text(encoding="utf-8")
assert "const STT_MAX_QUEUED_BYTES=1048576" in source
assert "const STT_MAX_ARCHIVE_BYTES=2880000" in source
assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source
assert "Streaming transcription backpressure limit exceeded" in source
assert "if(flushTimer||settled||!queue.length" in source
assert "flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)" in source
assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source
assert "takeFallbackBlob:function()" in source
assert "pcm16WavBlob(archive,16000)" in source
assert "if(pcmFallback)" in source

View File

@ -175,7 +175,8 @@ def test_release_candidate_streaming_keeps_one_ahead_and_safe_fallbacks():
assert "At most one later synthesis request exists" in script
assert "const nextPrepared=next.then" in script
assert "await playPrepared(asset,turn.token)" in script
assert "playBlob(await fetchSpeech(asset.chunk,asset.language,asset.turnId),token)" in script
assert "playBlob(await fetchSpeech(asset.chunk,asset.language,asset.turnId,token),token)" in script
assert "await closePcmBeforeBlob(playbackSession,token)" in script
assert "if(asset.started) throw error" in script
assert "playbackSession.controllers.forEach(function(controller){controller.abort();})" in script
assert "sendJson({type:'cancel',turn_id:turnId})" in script

View File

@ -674,6 +674,53 @@ def test_adaptive_chunks_wait_for_sentence_then_change_size(voice_probe):
assert "".join(chunks).replace(" ", "")
def test_spoken_http_urls_are_skipped_without_mangling_prose(voice_probe):
result = voice_probe["spoken_urls_are_skipped_without_damaging_text"]
assert result["sentence"] == "Read this. Then continue."
assert result["wrapped"] == "Open. Next."
assert result["punctuated"] == "Try, or!"
assert result["domains"] == "Keep example.com and sub.example.org exactly as written."
assert result["prose"] == "No links here; keep this sentence exactly as written."
def test_url_elision_happens_before_every_localized_voice_route(voice_probe):
cases = voice_probe["spoken_urls_are_elided_before_all_voice_routes"]["results"]
assert [case["language"] for case in cases] == ["en", "ru", "es"]
for case in cases:
assert case["tts"]
spoken = " ".join(request["text"] for request in case["tts"])
assert "http://" not in spoken
assert "https://" not in spoken
assert "private.example" not in spoken
assert "example.com" in spoken
assert all(request["language"] == case["language"] for request in case["tts"])
def test_canonical_pcm_fallback_wav_is_runtime_valid(voice_probe):
result = voice_probe["canonical_pcm_fallback_builds_a_valid_wav"]
assert result == {
"type": "audio/wav",
"size": 50,
"riff": "RIFF",
"wave": "WAVE",
"format": 1,
"channels": 1,
"rate": 16000,
"bits": 16,
"data": 6,
"pcm": [0, 0, 255, 127, 0, 128],
}
def test_applied_aec_settings_are_runtime_fail_safe(voice_probe):
assert voice_probe["applied_aec_settings_are_fail_safe"] == {
"applied": True,
"rejected": False,
"unknown": True,
"unsupported": True,
}
def test_first_complete_sentence_reaches_tts_before_completion_callback(voice_probe):
result = voice_probe["first_sentence_speaks_before_stream_completion"]
assert result["beforeBoundary"] == 0
@ -756,6 +803,34 @@ def test_streaming_tts_payload_is_narrow_and_turn_bound(patched_webui):
}
def test_streaming_tts_payload_forwards_only_allowlisted_localized_cues(patched_webui):
routes = patched_webui.routes
assert routes._atlas_tts_stream_payload(
{
"text": "client text is ignored by the cue cache",
"language": "es",
"cue_id": "still_working",
"turn_id": "voice-turn-7:thinking-cue:2",
}
) == {
"model": "piper",
"input": "client text is ignored by the cue cache",
"speed": 1.0,
"language": "es",
"cue_id": "still_working",
"turn_id": "voice-turn-7:thinking-cue:2",
}
for hostile in ("invented", "../thinking", 7, None):
with pytest.raises(ValueError, match="invalid thinking cue"):
routes._atlas_tts_stream_payload(
{"text": "ignored", "language": "en", "cue_id": hostile}
)
with pytest.raises(ValueError, match="invalid thinking cue"):
routes._atlas_tts_stream_payload(
{"text": "ignored", "language": "fr", "cue_id": "thinking"}
)
# ---------------------------------------------------------------------------
# 6. Build-time enforcement and documented semantics
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,171 @@
"""Browser, proxy, and Flux delivery contracts for voice route preflight."""
from __future__ import annotations
import json
from types import SimpleNamespace
import yaml
from testing.tests.test_hermes_chat_support import ROOT, _documents
from testing.tests.test_hermes_voice_language_routing import patched_webui # noqa: F401
HERMES = ROOT / "services" / "hermes"
VOICE = ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js"
def test_webui_proxy_enforces_origin_exact_echo_and_no_store(
patched_webui, # noqa: F811
monkeypatch,
):
routes = patched_webui.routes
request_payload = {
"turn_id": "ef" * 16 + "-4-1",
"revision": 2,
"transcript": "Explain the safest non disruptive option",
}
monkeypatch.setattr(routes, "read_body", lambda _handler: request_payload, raising=False)
monkeypatch.setattr(
routes,
"j",
lambda _handler, payload, status=200, extra_headers=None: {
"status": status,
"payload": payload,
"headers": extra_headers,
},
raising=False,
)
trusted = {"value": True}
monkeypatch.setattr(
routes,
"_check_same_origin_browser_request",
lambda _handler: trusted["value"],
raising=False,
)
monkeypatch.setattr(
routes,
"bad",
lambda _handler, message, status=400: {"status": status, "error": message},
raising=False,
)
observed = {}
class Upstream:
def read(self, size):
observed["size"] = size
return json.dumps(
{
"turn_id": request_payload["turn_id"],
"revision": 2,
"tier": "deep",
"target": "atlas/auto/deep",
"advisory": True,
"private": "must be removed",
}
).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
def open_preflight(request, timeout):
observed["url"] = request.full_url
observed["body"] = json.loads(request.data)
observed["timeout"] = timeout
return Upstream()
monkeypatch.setattr(routes, "_atlas_voice_preflight_open", open_preflight)
result = routes._handle_atlas_voice_preflight(SimpleNamespace())
assert observed["url"] == routes.ATLAS_VOICE_PREFLIGHT_URL
assert observed["body"] == request_payload
assert observed["timeout"] == 1.0
assert result["headers"] == {"Cache-Control": "no-store"}
assert result["payload"] == {
"turn_id": request_payload["turn_id"],
"revision": 2,
"tier": "deep",
"target": "atlas/auto/deep",
"advisory": True,
}
trusted["value"] = False
assert routes._handle_atlas_voice_preflight(SimpleNamespace())["status"] == 403
def test_browser_debounces_stable_partials_and_never_forces_final_route():
source = VOICE.read_text(encoding="utf-8")
partial = source.split("payload.type==='partial'", 1)[1].split(
"payload.type==='final'", 1
)[0]
scheduler = source.split("function scheduleVoicePreflight", 1)[1].split(
"function cancelThinkingCues", 1
)[0]
send = source.split("async function sendTranscript", 1)[1].split(
"function audioExtension", 1
)[0]
assert "VOICE_PREFLIGHT_DEBOUNCE_MS=200" in source
assert "window.crypto.getRandomValues(bytes)" in source
assert "scheduleVoicePreflight(turnId,revision,stable)" in partial
assert "cancelVoicePreflight(turnId)" in source
assert "controller.abort()" in scheduler
assert "advisory.turn_id!==turnId" in scheduler
assert "advisory.revision!==revision" in scheduler
assert "label.textContent='Listening · '+preview+' · '+tier" in scheduler
assert "composer.value" not in scheduler
assert "window.send" not in scheduler
assert "advisory" not in send
assert "atlas/auto/" not in send
def test_flux_wires_the_sibling_runtime_rollout_service_and_narrow_policy():
kustomization = yaml.safe_load((HERMES / "kustomization.yaml").read_text())
generator = next(
item
for item in kustomization["configMapGenerator"]
if item["name"] == "hermes-coordinator"
)
assert "voice_route_preflight.py=scripts/voice_route_preflight.py" in generator["files"]
deployment = _documents(HERMES / "switchyard-deployment.yaml")[0]
template = deployment["spec"]["template"]
assert template["metadata"]["annotations"]["ai.bstein.dev/config-rev"].endswith(
"voice-route-preflight-v1"
)
classifier = next(
item for item in template["spec"]["containers"] if item["name"] == "classifier-broker"
)
env = {item["name"]: item["value"] for item in classifier["env"]}
assert env["HERMES_VOICE_PREFLIGHT_MODEL"] == "qwen2.5:14b-instruct-q4_0"
service = _documents(HERMES / "switchyard-service.yaml")[0]
ports = {item["name"]: item["port"] for item in service["spec"]["ports"]}
assert ports["voice-preflight"] == 9009
voice_port = next(
item for item in service["spec"]["ports"] if item["name"] == "voice-preflight"
)
assert voice_port["targetPort"] == "voice-preflight"
assert service["metadata"]["annotations"]["prometheus.io/port"] == "9009"
policies = {
item["metadata"]["name"]: item
for item in _documents(HERMES / "networkpolicy.yaml")
}
ingress = policies["hermes-switchyard-isolation"]["spec"]["ingress"]
preflight_rules = [
rule
for rule in ingress
if any(port["port"] == 9009 for port in rule.get("ports", []))
]
assert len(preflight_rules) == 2
assert preflight_rules[0]["from"][0]["podSelector"]["matchLabels"] == {
"app": "hermes-chat-tenant"
}
assert preflight_rules[1]["from"][0]["namespaceSelector"]["matchLabels"] == {
"kubernetes.io/metadata.name": "monitoring"
}
chat = (HERMES / "chat-statefulset.yaml").read_text()
assert "HERMES_WEBUI_VOICE_PREFLIGHT_URL" not in chat

View File

@ -13,6 +13,8 @@ def test_voice_pipeline_binds_component_source_digest_and_release() -> None:
pipeline = PIPELINE.read_text(encoding="utf-8")
assert "IMAGE_COMPONENT must be stt or tts" in pipeline
assert "git merge-base --is-ancestor" in pipeline
assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in pipeline
assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in pipeline
assert "git status --porcelain" in pipeline
assert "Dockerfile.hermes-jetson-${component}" in pipeline
assert "--digest-file=" in pipeline
@ -33,6 +35,8 @@ def test_voice_pipeline_binds_component_source_digest_and_release() -> None:
assert "--compressed-caching=false" in pipeline
assert "--snapshot-mode=redo" in pipeline
assert "privileged: true" not in pipeline
assert "testing/tests/test_hermes_stt_streaming.py" in pipeline
assert "testing/tests/test_hermes_tts_language_routing.py" in pipeline
def test_jenkins_declares_one_token_guarded_voice_job() -> None:

View File

@ -0,0 +1,415 @@
"""Release gates for bounded, advisory-only voice route preflight."""
from __future__ import annotations
import importlib.util
import json
import sys
import threading
import time
from http.server import ThreadingHTTPServer
from types import SimpleNamespace
from urllib.error import HTTPError
from urllib.request import Request, urlopen
import pytest
from testing.tests.test_hermes_chat_support import ROOT
HERMES = ROOT / "services" / "hermes"
def _load_runtime(monkeypatch):
"""Load the sibling runtime modules without requiring httpx in image CI."""
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace())
preflight_path = HERMES / "scripts" / "voice_route_preflight.py"
spec = importlib.util.spec_from_file_location("voice_route_preflight", preflight_path)
assert spec and spec.loader
preflight = importlib.util.module_from_spec(spec)
spec.loader.exec_module(preflight)
monkeypatch.setitem(sys.modules, "voice_route_preflight", preflight)
broker_path = HERMES / "scripts" / "classifier_broker.py"
spec = importlib.util.spec_from_file_location("classifier_broker", broker_path)
assert spec and spec.loader
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
return preflight, broker
@pytest.mark.parametrize(
"payload",
[
{},
{"turn_id": "bad/id", "revision": 1, "transcript": "long enough text"},
{"turn_id": "nonce-1", "revision": True, "transcript": "long enough text"},
{"turn_id": "nonce-1", "revision": 0, "transcript": "long enough text"},
{"turn_id": "nonce-1", "revision": 1, "transcript": None},
{"turn_id": "nonce-1", "revision": 1, "transcript": "short"},
{"turn_id": "nonce-1", "revision": 1, "transcript": "x" * 513},
],
)
def test_preflight_input_bounds_fail_closed(monkeypatch, payload):
preflight, _ = _load_runtime(monkeypatch)
with pytest.raises(ValueError):
preflight.validate_request(payload)
def test_preflight_is_same_14b_model_and_never_hosts_a_foreground_turn(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
payload = preflight.inference_payload("Please compare these two safe approaches")
assert preflight.MODEL == "qwen2.5:14b-instruct-q4_0"
assert payload["model"] == preflight.MODEL
assert payload["stream"] is True
assert payload["max_tokens"] <= 16
assert payload["temperature"] == 0
assert "tools" not in payload
assert "provider" not in payload
assert preflight.TIMEOUT_SECONDS <= 0.75
def test_preflight_is_once_per_random_browser_turn_and_authority_preempts(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
coordinator = preflight.Coordinator()
cancel = coordinator.begin("8f" * 16 + "-1-1")
assert cancel is not None
class Resource:
closed = False
def close(self):
self.closed = True
client = Resource()
response = Resource()
assert coordinator.register(cancel, client=client, response=response)
started = time.monotonic()
coordinator.begin_authoritative()
assert time.monotonic() - started < 0.1
assert cancel.is_set()
assert client.closed and response.closed
assert coordinator.begin("9e" * 16 + "-1-1") is None
coordinator.end_authoritative()
coordinator.end()
# A completed browser turn cannot spend another 750 ms on a later partial.
assert coordinator.begin("8f" * 16 + "-1-1") is None
def test_coordinator_prunes_seen_turns_and_tolerates_close_failures(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
coordinator = preflight.Coordinator()
now = time.monotonic()
coordinator.seen = {f"turn-{index}": now for index in range(300)}
coordinator._prune(now)
assert len(coordinator.seen) == preflight.MAX_SEEN_TURNS
class BrokenResource:
def close(self):
raise RuntimeError("close failed")
cancel = coordinator.begin("fresh-turn")
assert cancel is not None
assert coordinator.register(cancel, client=BrokenResource(), response=BrokenResource())
coordinator.begin_authoritative()
assert cancel.is_set()
coordinator.end_authoritative()
coordinator.end_authoritative()
coordinator.end()
unrelated = threading.Event()
assert coordinator.register(unrelated) is False
assert unrelated.is_set()
idle = preflight.Coordinator()
idle.begin_authoritative()
idle.end_authoritative()
def _stream_with_fake_httpx(
monkeypatch,
preflight,
coordinator,
lines,
*,
fail_build=False,
close_raises=False,
preempt_on_send=False,
):
"""Drive the streamed parser and cleanup branches without network I/O."""
class Response:
def raise_for_status(self):
return None
def iter_lines(self):
yield from lines
def close(self):
if close_raises:
raise RuntimeError("response close failed")
response = Response()
class Client:
def __init__(self, timeout):
self.timeout = timeout
def build_request(self, *args, **kwargs):
if fail_build:
raise RuntimeError("build failed")
return (args, kwargs)
def send(self, request, stream=False):
assert stream is True
if preempt_on_send:
coordinator.begin_authoritative()
return response
def close(self):
if close_raises:
raise RuntimeError("client close failed")
monkeypatch.setattr(
preflight,
"httpx",
SimpleNamespace(Timeout=lambda *args, **kwargs: (args, kwargs), Client=Client),
)
cancel = coordinator.begin("stream-" + str(time.monotonic_ns()))
assert cancel is not None
result = coordinator.stream("This provisional request is stable", cancel)
if preempt_on_send:
coordinator.end_authoritative()
coordinator.end()
return result
def test_stream_parser_accepts_only_valid_bounded_json(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
coordinator = preflight.Coordinator()
content = json.dumps({"tier": "deep"})
lines = [
"ignored",
"data: not-json",
'data: {"choices":[]}',
'data: {"choices":[{"delta":{"content":7}}]}',
"data: "
+ json.dumps({"choices": [{"delta": {"content": content}}]}),
"data: [DONE]",
]
assert _stream_with_fake_httpx(monkeypatch, preflight, coordinator, lines) == "deep"
assert 'outcome="success"} 1' in coordinator.metrics()
invalid = preflight.Coordinator()
lines = [
"data: "
+ json.dumps(
{"choices": [{"delta": {"content": json.dumps({"tier": "hosted"})}}]}
)
]
assert _stream_with_fake_httpx(monkeypatch, preflight, invalid, lines) == ""
assert 'outcome="failure"} 1' in invalid.metrics()
def test_stream_fails_closed_on_oversize_preemption_and_cleanup_errors(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
oversized = preflight.Coordinator()
line = "data: " + json.dumps({"choices": [{"delta": {"content": "x" * 129}}]})
assert _stream_with_fake_httpx(monkeypatch, preflight, oversized, [line]) == ""
assert 'outcome="cancelled"} 1' in oversized.metrics()
preempted = preflight.Coordinator()
assert (
_stream_with_fake_httpx(
monkeypatch, preflight, preempted, [], preempt_on_send=True
)
== ""
)
assert 'outcome="cancelled"} 1' in preempted.metrics()
failed = preflight.Coordinator()
assert (
_stream_with_fake_httpx(
monkeypatch,
preflight,
failed,
[],
fail_build=True,
close_raises=True,
)
== ""
)
assert 'outcome="failure"} 1' in failed.metrics()
def test_stream_rejects_a_cancelled_reservation_before_network(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
coordinator = preflight.Coordinator()
cancel = coordinator.begin("cancel-before-network")
assert cancel is not None
cancel.set()
closed = {"value": False}
class Client:
def __init__(self, timeout):
self.timeout = timeout
def close(self):
closed["value"] = True
monkeypatch.setattr(
preflight,
"httpx",
SimpleNamespace(Timeout=lambda *args, **kwargs: None, Client=Client),
)
assert coordinator.stream("This request will be cancelled", cancel) == ""
assert closed["value"] is True
assert 'outcome="cancelled"} 1' in coordinator.metrics()
coordinator.end()
def test_absolute_deadline_closes_a_stalled_stream(monkeypatch):
preflight, _ = _load_runtime(monkeypatch)
monkeypatch.setattr(preflight, "TIMEOUT_SECONDS", 0.05)
closed = threading.Event()
class Response:
def raise_for_status(self):
return None
def iter_lines(self):
closed.wait(1)
if False:
yield ""
def close(self):
closed.set()
response = Response()
class Client:
def __init__(self, timeout):
self.timeout = timeout
def build_request(self, *args, **kwargs):
return (args, kwargs)
def send(self, request, stream=False):
assert stream is True
return response
def close(self):
response.close()
monkeypatch.setattr(
preflight,
"httpx",
SimpleNamespace(Timeout=lambda *args, **kwargs: (args, kwargs), Client=Client),
)
coordinator = preflight.Coordinator()
cancel = coordinator.begin("ab" * 16 + "-2-1")
assert cancel is not None
started = time.monotonic()
assert coordinator.stream("This provisional request is stable", cancel) == ""
elapsed = time.monotonic() - started
coordinator.end()
assert elapsed < 0.25
assert 'outcome="timeout"} 1' in coordinator.metrics()
def test_preflight_is_warm_only_and_final_payload_semantics_never_change(monkeypatch):
preflight, broker = _load_runtime(monkeypatch)
coordinator = preflight.Coordinator()
authoritative = {
"model": "qwen2.5:14b-instruct-q4_0",
"messages": [
{"role": "system", "content": "normal routing contract"},
{"role": "user", "content": "Please compare the two deployments"},
],
"response_format": {"type": "json_object"},
}
before = broker.compact_payload(authoritative)
cancel = coordinator.begin("aa" * 16 + "-2-1")
assert cancel is not None
coordinator.end()
after = broker.compact_payload(authoritative)
assert before == after
assert not hasattr(coordinator, "cache")
assert not hasattr(coordinator, "remember")
assert not hasattr(coordinator, "take_hint")
assert not hasattr(preflight, "add_advisory_hint")
def _post(server, payload):
request = Request(
f"http://127.0.0.1:{server.server_port}/voice/route-preflight",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=2) as response:
return response.status, response.headers, response.read()
def test_handler_returns_exact_sanitized_binding_and_204_when_busy(monkeypatch):
preflight, broker = _load_runtime(monkeypatch)
coordinator = preflight.Coordinator()
monkeypatch.setattr(broker, "VOICE_PREFLIGHT", coordinator)
monkeypatch.setattr(coordinator, "stream", lambda transcript, cancel: "balanced")
server = ThreadingHTTPServer(("127.0.0.1", 0), broker.VoiceHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
payload = {
"turn_id": "cd" * 16 + "-3-1",
"revision": 4,
"transcript": "Private words that must never leave this boundary",
}
try:
status, headers, raw = _post(server, payload)
result = json.loads(raw)
assert status == 200
assert headers["Cache-Control"] == "no-store"
assert result == {
"turn_id": payload["turn_id"],
"revision": 4,
"tier": "balanced",
"target": "atlas/auto/balanced",
"advisory": True,
}
assert b"Private words" not in raw
status, headers, raw = _post(server, payload)
assert status == 204
assert headers["Cache-Control"] == "no-store"
assert raw == b""
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def test_public_and_authoritative_listeners_have_disjoint_route_surfaces(monkeypatch):
_, broker = _load_runtime(monkeypatch)
def post_status(handler, path):
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
request = Request(
f"http://127.0.0.1:{server.server_port}{path}",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=2) as response:
return response.status
except HTTPError as error:
return error.code
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
assert post_status(broker.Handler, "/voice/route-preflight") == 404
assert post_status(broker.VoiceHandler, "/v1/chat/completions") == 404

View File

@ -239,11 +239,16 @@ def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None:
"""The image gate validates HTTP responses, not its own source files."""
module = _load(SMOKE, "hermes_webui_smoke_contract")
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
release_identity = "exp-v0.52.181-git-0123456789abcdef-build-13"
root = (
b'<link href="static/hermes-agent.ico">'
b'<link href="static/hermes-agent-192.png">'
b'<link href="static/hermes-agent-512.png">'
b'<link href="static/hermes-brand.css?v=reviewed">'
+ (
"<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='"
f"{release_identity}';</script>"
).encode()
)
calls = []
@ -259,6 +264,18 @@ def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None:
)
if path == "/":
return _SmokeResponse(root, url)
if path == "/api/settings":
return _SmokeResponse(
json.dumps(
{"webui_bundle_version": release_identity}
).encode(),
url,
)
if path == "/sw.js":
return _SmokeResponse(
f"const CACHE_NAME='hermes-shell-{release_identity}';".encode(),
url,
)
if path in (
"/pwa/static/hermes-agent-192.png",
"/pwa/static/hermes-agent-512.png",
@ -270,11 +287,51 @@ def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None:
result = module.smoke("http://hermes.test/", opener=open_fixture)
assert result["manifest"] == "http://assets.hermes.test/pwa/manifest.json"
assert result["icon_requests"] == 3
assert result["release_identity"] == release_identity
assert calls.count("http://assets.hermes.test/pwa/static/hermes-agent-192.png") == 2
assert "http://assets.hermes.test/pwa/static/hermes-agent-512.png" in calls
assert calls.count("http://hermes.test/static/hermes-agent-192.png") == 1
def test_server_smoke_rejects_a_client_server_release_mismatch() -> None:
"""A build cannot publish the permanently stale banner regression."""
module = _load(SMOKE, "hermes_webui_smoke_release_mismatch")
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
client = "exp-v0.52.181-git-0123456789abcdef-build-13"
root = (
b'<link href="static/hermes-agent.ico">'
b'<link href="static/hermes-agent-192.png">'
b'<link href="static/hermes-agent-512.png">'
b'<link href="static/hermes-brand.css?v=reviewed">'
+ (
"<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='"
f"{client}';</script>"
).encode()
)
def open_mismatch(request, *, timeout):
assert timeout == 5
url = request.full_url
path = urlsplit(url).path
if path == "/manifest.json":
return _SmokeResponse(json.dumps(manifest).encode(), url)
if path == "/":
return _SmokeResponse(root, url)
if path == "/api/settings":
return _SmokeResponse(
json.dumps(
{"webui_bundle_version": client + "-different"}
).encode(),
url,
)
if path.endswith(".png"):
return _SmokeResponse(module.PNG_MAGIC + b"fixture", url)
raise AssertionError(f"unexpected smoke URL: {url}")
with pytest.raises(RuntimeError, match="identities do not match"):
module.smoke("http://hermes.test/", opener=open_mismatch)
def test_server_smoke_rejects_unbranded_served_manifest() -> None:
"""A successful health check cannot mask the upstream PWA identity."""
module = _load(SMOKE, "hermes_webui_smoke_unbranded")
@ -330,7 +387,7 @@ def test_empty_state_uses_canonical_character_without_inline_staff(
def test_release_patch_gives_every_shell_url_an_immutable_build_token(
tmp_path: Path,
) -> None:
"""Atlas image releases never share a browser cache key."""
"""Atlas releases share one server/client identity and browser cache key."""
target = _patched_fixture(tmp_path)
env = os.environ.copy()
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
@ -345,11 +402,27 @@ def test_release_patch_gives_every_shell_url_an_immutable_build_token(
)
index = (target / "static/index.html").read_text(encoding="utf-8")
worker = (target / "static/sw.js").read_text(encoding="utf-8")
routes = (target / "api/routes.py").read_text(encoding="utf-8")
panels = (target / "static/panels.js").read_text(encoding="utf-8")
token = "__WEBUI_VERSION__-git-0123456789abcdef-build-11"
assert token in index
assert token in worker
assert not re.search(r"__WEBUI_VERSION__(?!-git-0123456789abcdef-build-11)", index)
assert not re.search(r"__WEBUI_VERSION__(?!-git-0123456789abcdef-build-11)", worker)
assert (
'settings["webui_bundle_version"] = '
'f"{WEBUI_VERSION}-git-0123456789abcdef-build-11"'
) in routes
assert (
"settings.webui_bundle_version||settings.webui_version"
) in panels
assert "if(client===server) return;" not in panels
assert "if(banner) banner.style.display='none';" in panels
assert "if(_isBannerVisible()) return;" not in panels
assert (
"if(_isBannerVisible()){ clearInterval(_pollTimer);"
not in panels
)
@pytest.mark.parametrize("release_id", ["", "../escape", "UPPERCASE", "a" * 129])
@ -406,6 +479,11 @@ def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None:
assert "python /tmp/hermes-webui-manifest-patch.py" in dockerfile
assert "COPY dockerfiles/hermes-webui-release-patch.py" in dockerfile
assert "python /tmp/hermes-webui-release-patch.py" in dockerfile
assert 'settings["webui_bundle_version"]' in dockerfile
assert (
"settings.webui_bundle_version||settings.webui_version"
in dockerfile
)
assert (
"COPY dockerfiles/hermes-webui-manifest.json /tmp/hermes-webui-manifest.json"
in dockerfile

View File

@ -91,13 +91,15 @@ def test_webui_job_is_independent_bounded_and_main_only() -> None:
assert "stringParam('CONFIRM_PUBLISH', ''" in block
def test_pipeline_builds_latest_main_containing_reviewed_anchor() -> None:
"""Publish is explicit, evidence-bound, and handed only to Flux."""
def test_pipeline_builds_exact_reviewed_anchor_from_main() -> None:
"""Publish uses one reviewed source revision even when main later advances."""
source = PIPELINE.read_text(encoding="utf-8")
assert 'test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"' in source
assert 'test "${actual_revision}" = "$(git rev-parse origin/main)"' in source
assert 'test "${main_revision}" = "$(git rev-parse origin/main)"' in source
assert "git fetch --no-tags origin main" not in source
assert 'git merge-base --is-ancestor "${EXPECTED_SOURCE_REVISION}"' in source
assert 'git checkout --detach "${EXPECTED_SOURCE_REVISION}"' in source
assert 'test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"' in source
assert "dockerfiles/Dockerfile.hermes-webui" in source
assert "ci/scripts/hermes_webui_release.py" in source
assert "registry.bstein.dev/bstein/hermes-webui" in source
@ -111,6 +113,15 @@ def test_pipeline_builds_latest_main_containing_reviewed_anchor() -> None:
assert "ci/scripts/hermes_oci_promote.py" in source
assert "test_hermes_webui_brand.py" in source
assert "test_hermes_webui_release.py" in source
for voice_suite in (
"test_hermes_chat_quality.py",
"test_hermes_handsfree_stt.py",
"test_hermes_voice_full_duplex.py",
"test_hermes_thinking_voice_cues.py",
"test_hermes_voice_instrument.py",
"test_hermes_voice_language_routing.py",
):
assert voice_suite in source
for forbidden in ("kubectl ", "flux reconcile", "git push", "git commit"):
assert forbidden not in source