Rename the owner agent host to worker.bstein.dev (supersedes #41) #42

Merged
bstein merged 3 commits from feature/hermes-domain-rename-agent-worker-v2 into main 2026-08-21 23:16:05 +00:00
45 changed files with 3608 additions and 135 deletions
Showing only changes of commit ed98278981 - Show all commits

View File

@ -24,18 +24,41 @@ ADD --checksum=sha256:f7d01dde371555732c4c314111ac79672b1a5ce2fc19266ab42178fd8d
ADD --checksum=sha256:45754dfdebb3b8661c3fc564713772deec6e064feeb5b4e9594857dc7305193a --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/low/en_US-lessac-low.onnx.json?download=true \
/opt/models/piper/en_US-lessac-low.onnx.json
# Multilingual chat voice policy: English -> amy, Russian -> irina, Spanish ->
# claude (Mexican Spanish, the only "claude" voice rhasspy/piper-voices
# publishes; there is no es_ES-claude).
ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18 --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true \
/opt/models/piper/en_US-amy-medium.onnx
ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77 --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx.json?download=true \
/opt/models/piper/en_US-amy-medium.onnx.json
ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx?download=true \
/opt/models/piper/ru_RU-irina-medium.onnx
ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7 --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx.json?download=true \
/opt/models/piper/ru_RU-irina-medium.onnx.json
ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0 --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx?download=true \
/opt/models/piper/es_MX-claude-high.onnx
ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx.json?download=true \
/opt/models/piper/es_MX-claude-high.onnx.json
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
# Load the actual pinned voice during the ARM64 build. This catches package or
# model-format drift before the image can reach Flux.
RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
# Load every pinned voice during the ARM64 build, including the three baked
# for the multilingual chat policy. This catches package or model-format
# drift before the image can reach Flux.
RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx',p/'en_US-amy-medium.onnx',p/'ru_RU-irina-medium.onnx',p/'es_MX-claude-high.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
ENV HERMES_TTS_HOST=0.0.0.0 \
HERMES_TTS_PORT=9001 \
HERMES_TTS_VOICE=en_US-lessac-medium \
HERMES_TTS_VOICE=en_US-amy-medium \
HERMES_TTS_CACHE=/opt/models/piper \
OMP_NUM_THREADS=2 \
PYTHONDONTWRITEBYTECODE=1 \

View File

@ -90,12 +90,14 @@ PY
# Add the Atlas voice bridge as a narrow integration layer. It activates only
# when a tenant's server-side STT capability reports the private Jetson route.
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py
@ -109,6 +111,7 @@ 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 '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 \
&& grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \
@ -116,9 +119,16 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
&& grep -Fq '"language": detected_language' /opt/hermes/tools/transcription_tools.py \
&& grep -Fq "'language': detected" /opt/hermes-webui/api/upload.py \
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
&& /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes-webui/api/routes.py \
/opt/hermes-webui/api/gateway_chat.py
/opt/hermes-webui/api/upload.py \
/opt/hermes-webui/api/gateway_chat.py \
/opt/hermes/tools/transcription_tools.py
# Exercise the real server process in the target architecture before publish.
RUN set -eu; \

View File

@ -62,6 +62,34 @@ def _clean_transcript(result: dict) -> str:
return " ".join(kept).strip()
def _detected_language(result: object) -> str:
"""Return the bare ISO-639 code Whisper decoded with, or nothing at all.
``whisper.transcribe`` reports the language it auto-detected (or the one it
was told to use) as a plain lowercase token such as ``en``/``ru``/``yue``.
Anything that is not that exact shape is dropped rather than guessed at, so
a surprising model result can never become a downstream voice selector.
"""
if not isinstance(result, dict):
return ""
value = result.get("language")
if not isinstance(value, str):
return ""
code = value.strip().lower()
if not 2 <= len(code) <= 3 or not code.isascii() or not code.isalpha():
return ""
return code
def _transcription_payload(result: dict) -> dict:
"""Build the transcription contract: text plus the model's own language."""
return {
"text": _clean_transcript(result),
"model": MODEL_NAME,
"language": _detected_language(result),
}
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
@ -148,8 +176,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
no_speech_threshold=0.5,
verbose=False,
)
transcript = _clean_transcript(result)
_json(self, 200, {"text": transcript, "model": MODEL_NAME})
_json(self, 200, _transcription_payload(result))
except Exception as exc:
print(f"[stt] transcription failed: {exc}", flush=True)
_json(self, 500, {"error": "transcription failed"})

View File

@ -17,12 +17,50 @@ from piper import PiperConfig, PiperVoice, SynthesisConfig
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high")
CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper"))
MAX_TEXT_CHARS = 5000
ONNX_THREADS = max(1, int(os.getenv("HERMES_TTS_ONNX_THREADS", "4")))
VOICE_LOCK = threading.Lock()
# 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 "_"
# separators and any case are accepted; anything not present here falls back
# to DEFAULT_VOICE_NAME (safe English default), never an error and never an
# unbaked model.
LANGUAGE_VOICE_MAP = {
"en": "en_US-amy-medium",
"en-us": "en_US-amy-medium",
"ru": "ru_RU-irina-medium",
"ru-ru": "ru_RU-irina-medium",
"es": "es_MX-claude-high",
"es-mx": "es_MX-claude-high",
"es-es": "es_MX-claude-high",
}
BAKED_VOICE_NAMES = frozenset(LANGUAGE_VOICE_MAP.values())
DEFAULT_VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-amy-medium")
def normalize_language(value: object) -> str | None:
"""Lowercase and fold "_"/"-" separators; reject non-string/blank input."""
if not isinstance(value, str):
return None
normalized = value.strip().lower().replace("_", "-")
return normalized or None
def resolve_voice_name(language: object) -> str:
"""Map a client-supplied language to one of the baked policy voices.
Unknown, missing, or malformed language always resolves to the safe
default rather than raising, and the result is always a member of
BAKED_VOICE_NAMES.
"""
normalized = normalize_language(language)
if normalized is None:
return DEFAULT_VOICE_NAME
return LANGUAGE_VOICE_MAP.get(normalized, DEFAULT_VOICE_NAME)
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
@ -46,7 +84,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
if self.path != "/health":
_json(self, 404, {"error": "not found"})
return
_json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"})
_json(
self,
200,
{
"ok": True,
"voices": sorted(self.server.voices), # type: ignore[attr-defined]
"default_voice": self.server.default_voice_name, # type: ignore[attr-defined]
"device": "cpu",
},
)
def do_POST(self) -> None:
if self.path != "/v1/audio/speech":
@ -71,10 +118,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
return
speed = min(2.0, max(0.5, speed))
# Policy is driven ONLY by "language". A client-supplied "voice"
# field is deliberately never read here; it cannot override the
# allow-listed mapping.
voice_name = resolve_voice_name(payload.get("language"))
voice = self.server.voices[voice_name] # type: ignore[attr-defined]
output = io.BytesIO()
try:
with VOICE_LOCK, wave.open(output, "wb") as wav_file:
self.server.voice.synthesize_wav( # type: ignore[attr-defined]
voice.synthesize_wav(
text,
wav_file,
SynthesisConfig(length_scale=1.0 / speed),
@ -84,6 +137,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-TTS-Voice", voice_name)
self.end_headers()
self.wfile.write(audio)
except Exception as exc:
@ -91,30 +145,54 @@ class SpeechHandler(BaseHTTPRequestHandler):
_json(self, 500, {"error": "speech synthesis failed"})
def main() -> None:
"""Load the checksum-pinned voice from the image and serve it on CPU."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
model_path = CACHE_DIR / f"{VOICE_NAME}.onnx"
config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json"
def _load_voice(cache_dir: Path, voice_name: str, threads: int) -> PiperVoice:
model_path = cache_dir / f"{voice_name}.onnx"
config_path = cache_dir / f"{voice_name}.onnx.json"
if not model_path.exists() or not config_path.exists():
raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}")
raise RuntimeError(f"baked Piper voice is missing: {voice_name}")
with config_path.open("r", encoding="utf-8") as config_file:
config = PiperConfig.from_dict(json.load(config_file))
session_options = onnxruntime.SessionOptions()
session_options.intra_op_num_threads = ONNX_THREADS
session_options.intra_op_num_threads = threads
session_options.inter_op_num_threads = 1
session = onnxruntime.InferenceSession(
str(model_path),
sess_options=session_options,
providers=["CPUExecutionProvider"],
)
voice = PiperVoice(session=session, config=config, download_dir=CACHE_DIR)
return PiperVoice(session=session, config=config, download_dir=cache_dir)
def load_voices(cache_dir: Path, threads: int) -> dict[str, PiperVoice]:
"""Eagerly load all three policy voices.
Preload (not lazy-load-on-first-use) was chosen deliberately: measured
RSS on this model set is ~88MB for one voice and ~243MB for all three
(~+155MB versus the previous single-voice baseline), which comfortably
fits the pod's memory budget on the CPU-only voice node. Preloading
avoids a slow, request-serializing first synthesis per language and
keeps the fail-closed missing-model check (below) at process start
rather than deferring a possible crash to a live user request.
"""
return {name: _load_voice(cache_dir, name, threads) for name in sorted(BAKED_VOICE_NAMES)}
def main() -> None:
"""Load the checksum-pinned policy voices from the image and serve them on CPU."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
if DEFAULT_VOICE_NAME not in BAKED_VOICE_NAMES:
raise RuntimeError(
f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}"
)
voices = load_voices(CACHE_DIR, ONNX_THREADS)
print(
f"[tts] loaded Piper voice {VOICE_NAME} on CPU with {ONNX_THREADS} ONNX threads",
f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: "
+ ", ".join(sorted(voices)),
flush=True,
)
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
server.voice = voice # type: ignore[attr-defined]
server.voices = voices # type: ignore[attr-defined]
server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined]
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)

View File

@ -6,6 +6,9 @@ from pathlib import Path
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
# The WebUI imports the pinned agent's STT tooling from the same image, so the
# local-command transcription envelope is patched alongside the WebUI itself.
AGENT_ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
@ -16,6 +19,43 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
path.write_text(source.replace(before, after, count), encoding="utf-8")
def replace_between_exact(
path: Path, start: str, end: str, after: str = "", count: int = 1
) -> None:
"""Replace one exact, bounded upstream region and fail when the pin drifts."""
source = path.read_text(encoding="utf-8")
if source.count(start) != count or source.count(end) != count:
raise SystemExit(
f"Atlas voice patch context changed in {path}: {start[:80]!r}"
)
start_index = source.index(start)
end_index = source.index(end, start_index) + len(end)
path.write_text(
source[:start_index] + after + source[end_index:], encoding="utf-8"
)
def assert_absent(path: Path, *needles: str) -> None:
"""Fail the image build if a removed voice-choice surface remains."""
source = path.read_text(encoding="utf-8")
remaining = [needle for needle in needles if needle in source]
if remaining:
raise SystemExit(f"Atlas voice choice remains in {path}: {remaining!r}")
def remove_lines_containing(path: Path, *needles: str) -> None:
"""Remove all pinned translation entries for a retired settings control."""
source = path.read_text(encoding="utf-8")
for needle in needles:
if needle not in source:
raise SystemExit(f"Atlas voice patch context changed in {path}: {needle!r}")
lines = source.splitlines(keepends=True)
path.write_text(
"".join(line for line in lines if not any(n in line for n in needles)),
encoding="utf-8",
)
index = ROOT / "static/index.html"
replace_exact(
index,
@ -29,6 +69,16 @@ replace_exact(
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
'<option value="atlas">Atlas Jetson (private)</option><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
)
replace_exact(
index,
'''<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="">Default system voice</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
</div>''',
"",
)
replace_exact(
index,
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>',
@ -62,11 +112,33 @@ replace_exact(
)
ui = ROOT / "static/ui.js"
replace_exact(
ui,
''' const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
''',
"",
)
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
replace_exact(
ui,
" const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';\n",
"",
)
replace_exact(
ui,
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
"body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
)
replace_exact(
ui,
" voice: localStorage.getItem('hermes-tts-voice')||'',\n",
"",
count=2,
)
replace_exact(
ui,
@ -79,7 +151,177 @@ replace_exact(
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
)
panels = ROOT / "static/panels.js"
replace_exact(panels, " tts_voice:'hermes-tts-voice',\n", "")
replace_exact(
panels,
''' const ttsVoiceSel=$('settingsTtsVoice');
if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
''',
"",
)
replace_exact(
panels,
''' localStorage.setItem('hermes-tts-engine',this.value);
window._populateTtsVoices();
_schedulePreferencesAutosave();''',
''' localStorage.setItem('hermes-tts-engine',this.value);
_schedulePreferencesAutosave();''',
)
replace_between_exact(
panels,
" // Populate voice selector based on engine\n",
" // TTS rate/pitch sliders\n",
" // TTS speaker selection is intentionally server policy only.\n",
)
replace_exact(
panels,
"let _settingsSpeechChangedKeys=new Set();\n",
"let _settingsSpeechChangedKeys=new Set();\n"
"try{localStorage.removeItem('hermes-tts-voice');}catch(_){}\n",
)
boot = ROOT / "static/boot.js"
replace_exact(
boot,
''' voice: localStorage.getItem("hermes-tts-voice")||'',
''',
"",
)
replace_exact(
boot,
''' const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
''',
"",
)
replace_exact(
boot,
" body: JSON.stringify({text: clean, voice, rate, pitch})",
" body: JSON.stringify({text: clean, rate, pitch})",
)
replace_exact(
boot,
''' const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
''',
"",
)
replace_exact(boot, " tts_voice:'',\n", "")
replace_exact(boot, " ['tts_voice','hermes-tts-voice'],\n", "")
config = ROOT / "api/config.py"
replace_exact(config, ' "tts_voice": "",\n', "")
replace_exact(config, ' "tts_voice",\n', "")
replace_exact(
config,
''' if k == "tts_voice":
if not isinstance(v, str) or len(v) > 200 or "\\x00" in v:
continue
''',
"",
)
assert_absent(index, "settingsTtsVoice", "settings_label_tts_voice")
assert_absent(ui, "hermes-tts-voice", "voice:voice")
assert_absent(panels, "settingsTtsVoice", "tts_voice")
assert_absent(boot, "hermes-tts-voice", "tts_voice", "text: clean, voice")
assert_absent(config, '"tts_voice"')
i18n = ROOT / "static/i18n.js"
remove_lines_containing(
i18n,
"settings_label_tts_voice:",
"settings_desc_tts_voice:",
)
assert_absent(i18n, "settings_label_tts_voice", "settings_desc_tts_voice")
# The private Whisper service reports the language it decoded with. Carry that
# through the agent's local-command STT envelope so the WebUI can hand a voice
# hint to Piper instead of guessing the reply's language from its text.
transcription = AGENT_ROOT / "tools/transcription_tools.py"
replace_exact(
transcription,
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
logger.info(
"Transcribed %s via local STT command (%s, %d chars)",
Path(file_path).name,
normalized_model,
len(transcript_text),
)
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
''',
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
logger.info(
"Transcribed %s via local STT command (%s, %d chars)",
Path(file_path).name,
normalized_model,
len(transcript_text),
)
detected_language = ""
language_files = sorted(Path(output_dir).glob("*.language"))
if language_files:
try:
candidate = language_files[0].read_text(encoding="utf-8").strip().lower()
except (OSError, ValueError):
candidate = ""
if 2 <= len(candidate) <= 3 and candidate.isascii() and candidate.isalpha():
detected_language = candidate
return {
"success": True,
"transcript": transcript_text,
"provider": "local_command",
"language": detected_language,
}
''',
)
upload = ROOT / "api/upload.py"
replace_exact(
upload,
""" transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
""",
""" transcript = str(result.get('transcript') or '').strip()
detected = str(result.get('language') or '').strip().lower()
if not (2 <= len(detected) <= 3 and detected.isascii() and detected.isalpha()):
detected = ''
return j(handler, {'ok': True, 'transcript': transcript, 'language': detected})
""",
)
routes = ROOT / "api/routes.py"
replace_exact(
routes,
"def _tts_open(req, *, timeout=30, opener_factory=None):",
'''ATLAS_TTS_LANGUAGES = ("en", "ru", "es")
def _atlas_tts_language(body):
"""Return a plain, allow-listed en/ru/es code, or "" to send no language.
This is a trust boundary, not a parser. Only the exact normalized codes the
private Piper deployment bakes a voice for are forwarded; a missing field,
a wrong type, a region tag, padding, control characters, a traversal or
injection string, an oversized value, an object, an array, a number or a
client-supplied "voice" all resolve to "" and the language field is then
omitted entirely, so the Jetson service applies its own English default.
Coercing a malformed value into a supported code would let a browser
describe hostile input as a language we support; omission cannot.
"""
if not isinstance(body, dict):
return ""
value = body.get("language")
if not isinstance(value, str):
return ""
return value if value in ATLAS_TTS_LANGUAGES else ""
def _tts_open(req, *, timeout=30, opener_factory=None):''',
)
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
if engine == "atlas":
@ -94,12 +336,19 @@ atlas = ''' # ── Atlas private Jetson TTS ──────────
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
except ValueError:
speed = 1.0
request_body = json.dumps({
request_payload = {
"model": "piper",
"input": text,
"voice": "en_US-lessac-high",
"speed": speed,
}).encode("utf-8")
}
# Attach a language ONLY when the browser sent a plain allow-listed
# code. Omitting it is the fail-safe: the Jetson service then speaks
# its own English default, which is also what every partially rolled
# out combination of these components degrades to.
_atlas_language = _atlas_tts_language(data)
if _atlas_language:
request_payload["language"] = _atlas_language
request_body = json.dumps(request_payload).encode("utf-8")
request = Request(atlas_url, data=request_body, headers={
"Content-Type": "application/json",
"Accept": "audio/wav",

View File

@ -31,9 +31,37 @@
error:'Voice unavailable',
idle:'',
};
// The only language signal this file trusts is the one the private Whisper
// service returned for the audio of the turn currently being answered. It is
// bound to that turn's generation token and consumed exactly once.
let sttLanguage='';
let sttLanguageToken=-1;
const TTS_LANGUAGES=['en','ru','es'];
const originalAutoRead=window.autoReadLastAssistant;
const originalApplyPreference=window._applyVoiceModePref;
function normalizeSttLanguage(value){
if(typeof value!=='string') return '';
const code=value.trim().toLowerCase();
return TTS_LANGUAGES.indexOf(code)>=0?code:'';
}
function clearSttLanguage(){
sttLanguage='';
sttLanguageToken=-1;
}
function rememberSttLanguage(language, token){
sttLanguage=language||'';
sttLanguageToken=sttLanguage?token:-1;
}
function takeSttLanguage(token){
const language=sttLanguageToken===token?sttLanguage:'';
clearSttLanguage();
return language;
}
function toast(message){
if(typeof window.showToast==='function') window.showToast(message,3000);
}
@ -107,6 +135,7 @@
active=false;
thinkingSession=null;
clearErrorTimer();
clearSttLanguage();
stopCapture();
stopPlayback();
modeBtn.classList.remove('active');
@ -120,28 +149,36 @@
},delay||500);
}
function sendTranscript(transcript, token){
function sendTranscript(transcript, token, language){
if(!active||token!==generation) return;
const text=String(transcript||'').trim();
if(!text){restartSoon(token,350);return;}
if(!text){clearSttLanguage();restartSoon(token,350);return;}
composer.value=text;
if(typeof window.autoResize==='function') window.autoResize();
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
rememberSttLanguage(language,token);
setState('thinking');
if(typeof window.send==='function') window.send();
}
function audioExtension(mimeType){
const normalized=String(mimeType||'').toLowerCase();
if(normalized.indexOf('ogg')>=0) return 'ogg';
if(normalized.indexOf('mp4')>=0) return 'mp4';
return 'webm';
}
async function transcribe(blob, token){
if(!active||token!==generation) return;
setState('transcribing');
const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm';
const ext=audioExtension(blob.type);
const form=new FormData();
form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext}));
try{
const response=await fetch('/api/transcribe',{method:'POST',body:form});
const payload=await response.json().catch(function(){return {};});
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
sendTranscript(payload.transcript,token);
sendTranscript(payload.transcript,token,normalizeSttLanguage(payload.language));
}catch(error){
if(!active||token!==generation) return;
const message=errorMessage(error,'Private Whisper is unavailable');
@ -159,6 +196,7 @@
async function startListening(token){
if(!active||token!==generation) return;
stopCapture();
clearSttLanguage();
setState('listening');
try{
const capture=await navigator.mediaDevices.getUserMedia({
@ -182,19 +220,26 @@
audioContext.createMediaStreamSource(stream).connect(highpass);
highpass.connect(analyser);
const samples=new Uint8Array(analyser.fftSize);
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm'];
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/webm'];
const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||'';
const chunks=[];
const preRoll=[];
let initialChunk=null;
let heardSpeech=false;
let voiceFrames=0;
let noiseFloor=0.008;
let lastSpeech=Date.now();
const started=Date.now();
recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined);
let recordedMime=recorder.mimeType||mime||'';
recorder.ondataavailable=function(event){
if(!event.data||!event.data.size) return;
if(event.data.type) recordedMime=event.data.type;
if(heardSpeech){chunks.push(event.data);return;}
// MediaRecorder's first timeslice owns the container initialization
// (EBML/Opus headers for WebM, and equivalent headers for Ogg/MP4).
// Keep it separately while bounding the actual audio pre-roll.
if(!initialChunk){initialChunk=event.data;return;}
preRoll.push(event.data);
while(preRoll.length>3) preRoll.shift();
};
@ -207,7 +252,7 @@
recorder=null;
if(!active||token!==generation) return;
if(!heardSpeech||!chunks.length){restartSoon(token,300);return;}
transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token);
transcribe(new Blob(chunks,{type:recordedMime||'audio/webm'}),token);
};
recorder.start(250);
const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600);
@ -229,6 +274,7 @@
if(!heardSpeech&&voiceFrames>=3){
heardSpeech=true;
lastSpeech=now;
if(initialChunk){chunks.push(initialChunk);initialChunk=null;}
while(preRoll.length) chunks.push(preRoll.shift());
}else if(heardSpeech&&voiceNow){
lastSpeech=now;
@ -273,11 +319,15 @@
});
}
async function fetchSpeech(chunk){
async function fetchSpeech(chunk, language){
// `language` is only ever the private STT result for this turn. When it is
// absent the field is omitted entirely and the server picks English.
const request={text:chunk,engine:'atlas'};
if(language) request.language=language;
const response=await fetch('/api/tts',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:chunk,engine:'atlas'}),
body:JSON.stringify(request),
});
if(!response.ok){
const payload=await response.json().catch(function(){return {};});
@ -291,10 +341,12 @@
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
if(thinkingSession&&currentSession&&thinkingSession!==currentSession){
thinkingSession=null;
clearSttLanguage();
restartSoon(token,250);
return;
}
thinkingSession=null;
const language=takeSttLanguage(token);
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
if(!rows.length){restartSoon(token,250);return;}
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
@ -302,11 +354,11 @@
setState('speaking');
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,280):[text];
try{
let pending=fetchSpeech(chunks[0]);
let pending=fetchSpeech(chunks[0],language);
for(let index=0;index<chunks.length;index+=1){
if(!active||token!==generation) return;
const blob=await pending;
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1]);
if(index+1<chunks.length) pending=fetchSpeech(chunks[index+1],language);
await playBlob(blob,token);
}
}catch(error){
@ -324,6 +376,7 @@
const token=generation;
active=true;
clearErrorTimer();
clearSttLanguage();
modeBtn.classList.add('active');
toast('Hands-free private voice mode on');
if(typeof window.stopTTS==='function') window.stopTTS();

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Bound local-STT conversion errors in the pinned Hermes Agent source."""
import os
from pathlib import Path
ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
def replace_exact(path: Path, before: str, after: str) -> None:
"""Replace one exact upstream fragment, failing closed on image drift."""
source = path.read_text(encoding="utf-8")
if source.count(before) != 1:
raise SystemExit(f"Hermes STT patch context changed in {path}: {before[:80]!r}")
path.write_text(source.replace(before, after, 1), encoding="utf-8")
transcription = ROOT / "tools/transcription_tools.py"
replace_exact(
transcription,
""" except subprocess.CalledProcessError as e:
details = e.stderr.strip() or e.stdout.strip() or str(e)
logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
return None, f"Failed to convert audio for local STT: {details}"
""",
""" except subprocess.CalledProcessError as e:
details = e.stderr.strip() or e.stdout.strip() or str(e)
logger.error(
"ffmpeg conversion failed for %s: %s", file_path, details[-2000:]
)
return None, (
"Audio conversion failed: upload is invalid, incomplete, or uses an "
"unsupported codec"
)
""",
)

View File

@ -0,0 +1,349 @@
# Hermes multi-user capacity assessment
Status: read-only investigation, no production changes made. This document
answers "can Hermes safely handle more simultaneous users" with evidence from
the current `titan-iac` manifests/code, plus non-invasive live cluster reads.
It does not increase any replica count or concurrency cap.
## 0. Method and access boundary
- Static evidence: `services/hermes/**` manifests and `services/hermes/router/*.go`,
`services/hermes/scripts/*.py` on branch `wt/t_65356568`, `git log`/`git blame`
for recent drift.
- Live evidence: read via the in-cluster `atlas-observer` kubeconfig context.
This identity (`system:serviceaccount:hermes:hermes-agent`) is **observer-only**:
`kubectl -n hermes get pods/deploy/sts/hpa/pdb` all returned `Forbidden`.
`kubectl -n hermes auth can-i --list` confirms the only granted verbs are
cluster-scoped `get/list/watch` on `namespaces`, `nodes`, `persistentvolumes`,
storage APIs, and `nodes.metrics.k8s.io`, plus API discovery/health endpoints.
There is no namespace-scoped read access to pods, deployments, replica counts,
or pod-level resource usage in `hermes` (or any namespace) from this session.
This matches the standing finding that hermes-agent RBAC is observer-only.
- What this means for this report: **all replica counts, resource
requests/limits, probes, and affinity rules below are read from the Git
manifests** (which is what Flux actually reconciles, so it is a trustworthy
source for desired state) rather than confirmed against live pod status.
**Node-level capacity and current node CPU/memory utilization are live,
real-time cluster evidence** (`nodes.metrics.k8s.io` + `nodes` are both
readable). One non-invasive HTTP probe against `https://chat.bstein.dev/`
confirmed the public endpoint is live and redirecting to auth (`302`) at
the time of writing.
- No pods, deployments, config, or replica counts were changed. No load test
was executed against any environment.
## 1. Architecture and request path
```
browser
-> Traefik Ingress (chat.hermes.bstein.dev / chat.bstein.dev)
-> oauth2-proxy-hermes-chat (Deployment, replicas: 1, oauth2-proxy.yaml:153)
session state: Redis, externalized (--session-store-type=redis,
redis://hermes-oauth-sessions...:6379/1, oauth2-proxy.yaml:203-204)
-> hermes-chat-router (Deployment, replicas: 1, chat-router.yaml:10)
Go binary, services/hermes/router/main.go
in-process, PVC-persisted identity -> slot map (tenants.json)
-> hermes-chat-tenant-<slot>.hermes-chat-tenant (StatefulSet, replicas: 4,
chat-statefulset.yaml:13) — one pod per Keycloak subject, pinned by ordinal
webui (8787) -> gateway (8642) -> per-tenant sandbox (chat-sandbox-<ordinal>:9080)
-> hermes-switchyard (Deployment, replicas: 1, switchyard-deployment.yaml:10)
shared model router for ALL tenants + triage + Kanban
-> hermes-claude-broker / hermes-codex-broker (containers in hermes-agent pod,
replicas: 1, agent-deployment.yaml:10) / hermes-model-gate (Deployment,
replicas: 1, model-gate-deployment.yaml:10, proxies to local Ollama)
-> hermes-stt / hermes-tts (Deployments, replicas: 1 each, voice-deployment.yaml,
pinned to node titan-21, shared GPU)
```
Every component on this path is `replicas: 1` **except** `hermes-chat-tenant`
(4). The StatefulSet is not a load-balanced pool: each pod is a fully isolated,
permanently-assigned per-user backend, not interchangeable capacity.
## 2. Known limits (confirmed from manifests/code)
### 2.1 Hard ceiling: 4 usable chat users today, and a live configuration bug
`services/hermes/router/main.go` (`tenantRouter.slotFor`, lines 153-176)
permanently assigns each Keycloak subject a slot `0..TENANT_SLOTS-1` on first
login, persists it to a PVC-backed JSON file, and never releases it. There is
no unassign/deprovision path in the router. Unit tests
(`router/main_test.go`, "expected the fixed private tenant pool to report
capacity") assert that once all slots are taken, `slotFor` returns an error.
**Confirmed drift**: `chat-router.yaml:70` sets `TENANT_SLOTS=8`, but
`chat-statefulset.yaml:13` sets `replicas: 4`. `git log -p` on both files
shows they were changed together at `18965a2f` (4->8), then
`chat-statefulset.yaml` was scaled down twice — `79baa7ec` ("return two chat
slots to fit the agent runtime", 8->6) and `e28b32bd` (6->4) — **without a
matching change to `TENANT_SLOTS`**, and `chat-sandbox.yaml` still defines 8
per-ordinal sandbox Deployments (`hermes-chat-sandbox-0` through `-7`).
Net effect: the 5th–8th distinct Keycloak login is durably assigned a slot
that has no backing StatefulSet pod. `main.go`'s proxy error path (around
line 382) returns a "starting" error to that user **permanently**, not just
during a cold start, because the pod will never exist at the current replica
count. This is a pre-existing configuration bug, independent of any future
scale-up decision, and it silently masks the real behavior of the capacity
ceiling (a 5th user does not get a clean "at capacity" rejection matching the
tested behavior — they get a StatefulSet-pod-not-found style failure).
**This assessment does not fix it** (per scope: no production changes), but
it should be the first fix considered before any scale-out, since it affects
correctness at the current replica count too.
### 2.2 The real shared bottleneck is not the 4 tenant pods, it's the brokers behind them
All 4 tenant pods, the triage/operator instance, and Kanban/CLI-lane traffic
funnel through the same single-replica `hermes-switchyard`, which routes to
the same single-replica `hermes-claude-broker` / `hermes-codex-broker`
(containers inside the one `hermes-agent` pod) or `hermes-model-gate`.
- `hermes-claude-broker` enforces a hard, code-level concurrency cap:
`HERMES_CLAUDE_BROKER_CONCURRENCY=2` (`agent-deployment.yaml:1160`) sizes a
`threading.BoundedSemaphore(2)` (`scripts/claude_oauth_broker.py:63`)
wrapped around a blocking `subprocess.run` call to the native Claude Code
CLI. Requests over the cap **block silently** (no 429/503 to the caller)
until a slot frees. This cap is shared cluster-wide across every chat
tenant, the triage instance, and indirectly the Kanban/CLI-lane workers
(same provider credentials, so heavy CLI-lane usage can throttle chat
responses via the provider's own rate limits even without touching this
semaphore directly).
- `hermes-codex-broker` and `hermes-model-gate` have **no equivalent
concurrency cap in code** — they are bounded only by container CPU/memory
`limits` (1 CPU / 1Gi for the codex-broker container, 250m/128Mi for
model-gate) and by the single upstream Ollama/Jetson instance's real
throughput, which is unknown (no load test exists).
**Implication**: scaling `hermes-chat-tenant` replicas alone (e.g. 4 -> 8)
does not proportionally increase real AI-response throughput. The
Claude-routed share of traffic is capped at 2 concurrent completions
regardless of how many tenant pods exist, and Codex/local-model throughput
under concurrent load is untested.
### 2.3 Single points of failure and deploy-time outage windows
- No `HorizontalPodAutoscaler` exists anywhere in this repository (confirmed
by a repo-wide grep, not just `services/hermes/`).
- No `PodDisruptionBudget` exists for any hermes component (the one PDB in
the repo is `infrastructure/traefik/pdb.yaml`, unrelated).
- `oauth2-proxy`, `hermes-chat-router`, `hermes-switchyard`,
`hermes-model-gate`, `hermes-agent` (brokers), and voice STT/TTS are all
`replicas: 1`. A pod restart/crash/node drain on any of them takes down
chat, voice, or model access for **all** users simultaneously, even though
the tenant StatefulSet gives each user an isolated conversation backend.
- `hermes` (main/triage), `hermes-chat-router`, `hermes-local-image`, and
`hermes-agent` all use `strategy: Recreate` — every rollout is a full
teardown-then-recreate, not a rolling update, adding a real availability
gap bounded by readiness/liveness `initialDelaySeconds` (up to 90s+ for the
main `hermes` deployment). The only genuinely zero-downtime rollout path
in this whole service is `hermes-switchyard`
(`RollingUpdate`, `maxSurge:1`, `maxUnavailable:0`) and the tenant
StatefulSet itself (`RollingUpdate`, `podManagementPolicy: Parallel`).
- OAuth session storage (`hermes-oauth-sessions`, a single Redis
`Deployment`, `replicas: 1`, `oauth-session-store.yaml`) has no
replication; its loss logs out every user cluster-wide (sessions are
externalized from oauth2-proxy but not made highly available).
### 2.4 The cluster is a heterogeneous home-lab/edge fleet with real, uneven headroom
Live `nodes.metrics.k8s.io` reads (non-invasive) show 21 nodes: mostly
Raspberry Pi 4/5 workers (~3.6-4 vCPU / ~6.5-8GB allocatable each), plus a
few larger amd64/Jetson-class nodes with shared GPUs (titan-20/21, titan-22,
titan-24) and one large amd64 node (titan-23, 48 vCPU/256GB, no GPU, not
labeled `worker`). This is **not** elastic cloud capacity — headroom is
finite and already uneven:
| Node | CPU used/alloc | Mem used/alloc | Note |
|---|---|---|---|
| titan-12 | 3.95 / 3.60 (**110%**) | 5.65 / 6.50 (87%) | rpi4, already over its CPU allocatable at snapshot time |
| titan-13 | 3.45 / 3.60 (96%) | 5.85 / 6.50 (90%) | rpi4, hot |
| titan-17 | 2.71 / 3.60 (75%) | 5.54 / 6.50 (85%) | rpi4, hot on memory |
| titan-07 | 1.23 / 3.60 (34%) | 5.24 / 6.63 (79%) | rpi5, memory-constrained |
| titan-04 | 0.35 / 3.60 (10%) | 1.69 / 6.63 (26%) | rpi5, most headroom in the pool |
| titan-21 (voice, GPU) | 0.91 / 6.00 (15%) | 8.02 / 14.56 (55%) | shared GPU node, moderate headroom |
| titan-23 (48 vCPU/256GB, no GPU label) | 0.74 / 48 (1.5%) | 22.9 / 251.6 (9%) | large headroom, not currently used by hermes affinity rules (arm64-only selectors exclude it) |
| titan-24 (GPU, Wolf/Hermes shared) | 1.16 / 24 (4.8%) | 22.6 / 62.7 (36%) | model-gate/Ollama contention with Wolf, per NOTES.md |
`chat-statefulset.yaml`'s node affinity (lines 54-74) requires `arm64` +
`node-role.kubernetes.io/worker=true` and already excludes 7 specific
hostnames (`titan-05,08,13,14,17,18,19`) — but two of the nodes it currently
*can* still land on, **titan-12 and titan-15, are already running at 75-110%
of allocatable CPU**. Any additional tenant/broker/router replica scheduled
into this same arm64 worker pool inherits that contention; it is not safe to
assume "add a replica" has free capacity behind it without checking node
headroom at scale-out time. The large amd64 node (titan-23) has abundant
headroom but is excluded by the current `arm64`-only affinity rules on every
hermes component — worth a design question, not something this assessment
changes.
### 2.5 No staging/canary environment exists
`services/hermes/kustomization.yaml` is a single flat kustomization with no
overlay split. `services/hermes-chat/` contains only a namespace and one PVC
(vestigial, no workload). `services/hermes-triage-demo/` is unrelated
(Ariadne's automated-repair demo loop, no chat workload). **Any load test
that exercises the real chat path runs against the production namespace and
the production `hermes-chat-tenant` StatefulSet** — there is no isolated
copy to test against.
### 2.6 No load-test tooling and thin production metrics exist today
- No k6/locust/vegeta/wrk/artillery/ab scripts exist anywhere in the repo
for hermes. A load test has to be built from scratch.
- No `ServiceMonitor`/`PodMonitor`/`PrometheusRule` objects exist for hermes
(annotation-based scraping only). `/metrics` exists for the `hermes-agent`
pod (CLI/provider quota usage, port 9010), `hermes-switchyard` (port 9005,
the richest signal — `switchyard_requests_total`,
`switchyard_model_call_latency_ms_bucket`,
`switchyard_routing_overhead_ms_bucket`, `switchyard_errors_total`,
`switchyard_client_responses_total`), and the CLI-lane metrics service
(port 9011, agent/Kanban-lane specific). **No `/metrics` endpoint exists
on `hermes-chat-tenant`, `hermes-model-gate`, `oauth2-proxy`,
`hermes-chat-router`, or voice** — there is no per-request latency/error
signal from the actual multi-user chat pods themselves.
- `services/monitoring/grafana-dashboard-ai.yaml` already has a P95
model-call-latency panel, a success/error-rate panel, and a per-pod CPU
panel filtered to `hermes-(agent|chat-tenant|switchyard|model-gate).*`,
all sourced from Switchyard metrics + cAdvisor — a usable baseline to
reuse, but nothing is wired to an alert threshold, and there is no
queue-depth or replica-health panel.
## 3. Unknowns (require an actual test to answer)
- Real max throughput of `hermes-switchyard`, `hermes-model-gate` -> Ollama,
and the STT/TTS GPU path under concurrent load — no data exists today.
- Real behavior/latency of the Claude broker's semaphore-2 queue under
contention (does it degrade gracefully to slow responses, or eventually
time out and error?) — no data exists today.
- Whether oauth2-proxy + Redis session store can handle concurrent
logins/refreshes at a higher user count (currently untested; the
Deployment is stateless-safe to scale, but has never been scaled).
Also whether Redis itself (`hermes-oauth-sessions`, 1Gi PVC, no HA)
becomes a bottleneck or SPOF under more simultaneous sessions.
- Actual current pod-level CPU/memory usage and restart history for any
hermes component — not observable with the current RBAC grant. Only
node-level aggregate usage is visible, not which pods are consuming it.
- Whether scaling `hermes-chat-tenant` toward its already-configured
`TENANT_SLOTS=8` (after fixing the mismatch) is actually safe given
current node headroom (titan-12/titan-15 pressure, see 2.4) — needs a
scheduling dry-run / headroom check at decision time, not assumed from
this snapshot.
## 4. SLO and load-test proposal (not yet executed — needs approval, see §6)
### 4.1 Target SLOs to validate (proposed, for review)
| Metric | Proposed target | Source |
|---|---|---|
| P95 end-to-end chat response latency | < 8s for local/Codex-routed, < 20s for Claude-routed (queued behind semaphore-2) | new synthetic client timer + existing `switchyard_model_call_latency_ms_bucket` |
| Error rate (5xx / proxy failures) | < 1% sustained | new synthetic client + `switchyard_errors_total` / `switchyard_client_responses_total` |
| Login/session success rate | 100% (oauth2-proxy + Redis) | synthetic client HTTP status |
| Node CPU/mem headroom during test | stay below 90% allocatable on any node hosting hermes pods | `nodes.metrics.k8s.io` (already provably readable) |
| Claude-broker queue depth / wait time | document actual behavior, no target yet (unknown baseline) | needs new instrumentation — none exists today |
### 4.2 Concurrency / ramp / mix proposal
- **Concurrency levels**: 1 (baseline), 2 (at the Claude-broker cap), 4
(at today's real tenant-pool ceiling), and — only if §2.1's mismatch is
resolved and node headroom is re-checked — 8 (at the configured
`TENANT_SLOTS`). Do not exceed the number of actually-backed tenant slots.
- **Ramp**: hold each level for at least 10 minutes after reaching steady
state before increasing, to separate cold-start effects (webui/gateway
startup, model warm-up) from steady-state capacity.
- **Traffic mix**: proportion requests across the three model routes
(Claude / Codex / local Ollama) matching real usage if known, otherwise
test each route in isolation first (to characterize each bottleneck
independently — semaphore-2 vs. no-cap-but-untested), then a blended mix.
- **Session behavior**: each synthetic user must be a distinct, dedicated
test Keycloak identity (slot assignment is permanent — do not burn real
user slots or reuse production accounts for load generation).
- **Failure injection (optional, staged)**: kill the single
`hermes-switchyard` or `hermes-model-gate` pod mid-test to confirm the
`Recreate`/no-PDB SPOF behavior matches expectations and measure recovery
time, only after the throughput characterization above is complete.
## 5. Safe staged scaling plan (for future execution, after human approval)
This is a plan to evaluate, not a plan already executed.
1. **Fix the `TENANT_SLOTS`/replica-count mismatch first** (§2.1). This is a
correctness fix at the *current* capacity, not a capacity increase, and
should land before any load test so results aren't confounded by the
existing 502-on-phantom-slot bug.
2. **Instrument before scaling**: add `/metrics` (or at minimum structured
access logs) to `hermes-chat-tenant`, `hermes-chat-router`, and
`hermes-model-gate` so a load test has a real per-request signal instead
of relying solely on Switchyard's aggregate view.
3. **Baseline test at today's real ceiling (4 users)**, off-hours, using
dedicated test Keycloak identities, per §4.
4. **Re-check node headroom** (§2.4) at decision time, not from this
snapshot, before considering any StatefulSet scale-out — titan-12 and
titan-15 were already at/above CPU allocatable during this assessment.
5. **If and only if** the baseline is healthy and headroom allows, propose
scaling `hermes-chat-tenant` toward the already-configured 8 slots one
or two ordinals at a time (StatefulSet `podManagementPolicy: Parallel`
supports this), re-measuring node headroom and Switchyard/broker latency
after each step.
6. **Address the shared-broker ceiling separately from tenant-pod count**:
raising `HERMES_CLAUDE_BROKER_CONCURRENCY` above 2 is a distinct decision
gated by the underlying Claude Code provider's real rate limits, not by
Kubernetes resources — do not conflate it with StatefulSet scaling.
7. **Only after tenant/broker capacity is validated**, consider whether the
single-replica shared components (`hermes-switchyard`, `hermes-model-gate`,
`oauth2-proxy`) need a second replica or a PDB — note oauth2-proxy is the
only one of these that is currently stateless-safe to scale (Redis-backed
sessions); the others would need code changes first (in-process locks/
state noted in §2).
## 6. Rollback and observability gates
- Every step above is a Git-reviewed manifest change applied via Flux, so
rollback is `git revert` + Flux reconcile, consistent with this repo's
existing GitOps model — no manual `kubectl edit` in any step.
- Gate each scaling step on: node CPU/mem headroom (§2.4 thresholds), the
new `/metrics` signal from step 2 above staying within the SLOs in §4.1,
and zero increase in `switchyard_errors_total` / `switchyard_classifier_fail_open_total`
rate versus the pre-change baseline.
- Because there is no staging environment (§2.5) and no PDB, treat every
step as a production change: schedule off-hours, announce before/after,
and keep the prior manifest revision ready to revert immediately if error
rate or node pressure crosses the gate.
- Because RBAC here is observer-only (§0), whoever executes a future load
test needs either elevated read access to confirm live pod/replica status
during the test, or must rely entirely on the node-metrics + Switchyard
signals already proven readable in this assessment.
## 7. Recommendation
**A load test against the current 4-user ceiling is reasonably safe to run
off-hours with dedicated test accounts, once §5 step 1 (the `TENANT_SLOTS`
mismatch) and step 2 (baseline metrics) are addressed** — it does not
require any replica/cap increase, and the blast radius is bounded to
resources already provisioned.
**A test at 8 concurrent users, or any StatefulSet/broker scale-up, is not
yet safe to schedule.** Two of the nodes in the current chat-tenant affinity
pool are already at or above their allocatable CPU/memory (§2.4), there is
no PDB/HPA anywhere in this service, there is no staging environment to
absorb the risk, and the real throughput ceiling for Codex/local-model
routes and the actual behavior of the Claude-broker queue under load are
both unmeasured. Recommend running the 4-user baseline test first and using
its results (plus a fresh node-headroom check) to decide whether 8-user
scaling is warranted — this assessment does not make that call.
## 8. Requested input / blockers to proceed further
This assessment can be delivered without further input. To actually execute
the load test proposed in §4, a human owner needs to provide or approve:
1. Sign-off to fix the `TENANT_SLOTS`/replica mismatch (§2.1) as a
correctness PR, separate from any capacity increase.
2. Dedicated test Keycloak identities (slot assignment is permanent —
production user accounts must not be used for load generation).
3. An approved off-hours maintenance window, since there is no staging
environment and every step in §5 is a production change.
4. A decision on whether to add `/metrics` instrumentation (§5 step 2)
before or in parallel with the first baseline test, since today's
Switchyard-only signal cannot attribute latency/errors to a specific
tenant pod.
5. If deeper live verification is wanted beyond node-level metrics
(e.g., live pod status/restart counts during a test), temporary
elevated read RBAC for the executing identity, since the current
`atlas-observer` grant cannot list pods/deployments in `hermes` (§0).

View File

@ -148,7 +148,7 @@ INFRA_REGEX = f"^({'|'.join(INFRA_PATTERNS)})$"
CP_ALLOWED_NS = INFRA_REGEX
LONGHORN_NODE_REGEX = "titan-1[2-9]|titan-2[2-4]"
ALL_NODE_REGEX = "|".join(CONTROL_ALL + WORKER_NODES)
GAUGE_WIDTHS = [4, 3, 3, 4, 3, 3, 4]
GAUGE_WIDTHS = [3, 3, 3, 3, 3, 3, 3, 3]
CONTROL_WORKLOADS_EXPR = (
f'sum(kube_pod_info{{node=~"{CONTROL_REGEX}",namespace!~"{CP_ALLOWED_NS}"}}) or on() vector(0)'
)
@ -467,21 +467,18 @@ UPTIME_WINDOW = "365d"
UPTIME_RECORDING_METRIC = (
f'atlas:availability:ratio_{UPTIME_WINDOW}{{scope="atlas",definition="request-v4"}}'
)
AVAILABILITY_REQUESTS_1H_EXPR = (
'sum(increase(traefik_entrypoint_requests_total{'
'entrypoint="websecure",protocol="http",code=~"[1-5].."}[1h]))'
)
AVAILABILITY_FAILURES_1H_EXPR = (
'sum(increase(traefik_entrypoint_requests_total{'
'entrypoint="websecure",protocol="http",code=~"5.."}[1h]))'
)
UPTIME_LIVE_FALLBACK_EXPR = (
f"(1 - (({AVAILABILITY_FAILURES_1H_EXPR} or on() vector(0)) / "
f"clamp_min({AVAILABILITY_REQUESTS_1H_EXPR}, 1)))"
)
UPTIME_RECORDING_EXPR = (
f"(last_over_time({UPTIME_RECORDING_METRIC}[48h]) "
f"or on() {UPTIME_LIVE_FALLBACK_EXPR})"
# The 2026-08-18 storage outage proved a fallback here lies twice at once:
# when the yearly sample went stale the panel silently rendered the last hour
# of Traefik traffic under a 365d title, while the unmeasured gap vanished
# from the yearly ratio as if it had been observed. A stale rollup must render
# no value and page (atlas-availability-rollup-stale), never substitute a
# different measurement under the same label.
UPTIME_RECORDING_EXPR = f"last_over_time({UPTIME_RECORDING_METRIC}[48h])"
# Days of daily availability rollups the yearly figure actually rests on,
# published by the same rollup job so freshness matches the ratio sample.
UPTIME_COVERAGE_EXPR = (
"last_over_time(atlas:availability:coverage_days_365d"
'{scope="atlas",definition="request-v4"}[48h])'
)
# Tie-breaker to deterministically pick one node per namespace when shares tie.
@ -511,6 +508,18 @@ UPTIME_PERCENT_THRESHOLDS = {
{"color": "blue", "value": 0.99999},
],
}
# Coverage is a disclosure, not an SLO: a short history is honest, a shrinking
# one means telemetry is being lost. The request-v4 definition was backfilled
# to 2026-05-01, so the figure legitimately sits well below a full year.
UPTIME_COVERAGE_THRESHOLDS = {
"mode": "absolute",
"steps": [
{"color": "red", "value": None},
{"color": "orange", "value": 7},
{"color": "yellow", "value": 30},
{"color": "green", "value": 90},
],
}
PROBLEM_TABLE_EXPR = (
"(time() - kube_pod_created{pod!=\"\"}) "
"* on(namespace,pod) group_left(node) kube_pod_info "
@ -1910,7 +1919,8 @@ OVERVIEW_PANEL_DESCRIPTIONS = {
"Control Plane Ready": "Control-plane nodes currently Ready; full count is good, lower means Kubernetes core capacity is missing.",
"Control Plane Workloads": "Non-core pods running on control-plane nodes; zero is good because control nodes should stay focused.",
"Stuck Terminating": "Pods that Kubernetes cannot finish deleting; zero is good, growth means cleanup or storage may be stuck.",
"Atlas Availability (365d)": "Request-weighted Atlas ingress availability; every server-side 5xx response counts as a failed request.",
"Atlas Availability (365d window)": "Request-weighted Atlas ingress availability over measured days only; telemetry gaps are excluded from the ratio, never counted as downtime.",
"Availability Coverage (days)": "Days of rollup history behind the availability figure; a telemetry gap lowers this instead of lowering availability.",
"Problem Pods": "Current-service pods Pending for more than 15 minutes or in an actionable failed phase. Completed Jobs and retained Veles migration workloads are kept on drill-down dashboards but excluded here.",
"CrashLoop / ImagePull": "Current-service pods stuck in CrashLoopBackOff or ImagePullBackOff for more than 15 minutes. Retained Veles migration workloads remain visible on the Pods dashboard.",
"Workers Ready": "Worker nodes currently Ready; full count is good, lower means less place to run services.",
@ -2143,7 +2153,7 @@ def build_overview():
},
{
"id": 27,
"title": "Atlas Availability (365d)",
"title": "Atlas Availability (365d window)",
"expr": UPTIME_PERCENT_EXPR,
"kind": "stat",
"thresholds": UPTIME_PERCENT_THRESHOLDS,
@ -2151,7 +2161,19 @@ def build_overview():
"decimals": 4,
"text_mode": "value",
"instant": True,
"description": "Rolling request-weighted availability at the Atlas HTTPS ingress: responses below 500 divided by all HTTP responses. Every server-side 5xx is a real failed request; client 4xx responses count as served. Replica counts, Grafana health, and monitoring gaps are not inputs, so they cannot lower availability unless user traffic actually receives a 5xx. A daily rollup job publishes one annual sample from deduplicated daily totals; Grafana keeps it for up to 48 hours so one delayed retry cannot cause a fallback, and only uses the same one-hour request SLI before history exists.",
"description": "Request-weighted availability at the Atlas HTTPS ingress over the trailing year: responses below 500 divided by all HTTP responses, read from the daily rollup sample. Every server-side 5xx is a real failed request; client 4xx responses count as served. Only measured days enter the ratio: a telemetry gap such as the 2026-08-18 metrics-storage outage is excluded from both sides, never converted into downtime or uptime, and the Availability Coverage panel states how many days the figure actually rests on. If the rollup stops publishing, this panel goes stale and renders no value instead of quietly substituting a one-hour live ratio under a yearly title; the atlas-availability-rollup-stale alert pages first.",
},
{
"id": 36,
"title": "Availability Coverage (days)",
"expr": UPTIME_COVERAGE_EXPR,
"kind": "stat",
"thresholds": UPTIME_COVERAGE_THRESHOLDS,
"unit": "none",
"decimals": 0,
"text_mode": "value",
"instant": True,
"description": "Days of daily rollups the availability figure to the left actually rests on, out of the 365-day window. A telemetry gap such as the 2026-08-18 metrics-storage outage lowers this number instead of moving availability, which is what keeps an observability failure from being reported as Atlas downtime. The request-v4 definition begins on 2026-05-01, so coverage stays below a full year until that history accumulates.",
},
{
"id": 4,

View File

@ -0,0 +1,82 @@
"""Keep the public availability figure honest about what it measured."""
import json
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
# On 2026-08-18 the metrics volume filled and the availability pipeline failed
# in both directions at once: the stale yearly sample silently fell back to a
# one-hour Traefik ratio wearing the 365d title, and the 34-hour telemetry gap
# vanished from the yearly ratio as if it had been measured. These guardrails
# pin the corrections.
def _overview_panels() -> list[dict]:
"""Return the generated Overview dashboard's top-level panels."""
dashboard = json.loads(
(REPO_ROOT / "services/monitoring/dashboards/atlas-overview.json").read_text()
)
return dashboard["panels"]
def _panel(title_prefix: str) -> dict:
return next(
panel
for panel in _overview_panels()
if str(panel.get("title", "")).startswith(title_prefix)
)
def _panel_exprs(panel: dict) -> str:
return " ".join(target.get("expr", "") for target in panel.get("targets", []))
def _alert_rules() -> dict[str, dict]:
"""Return every provisioned Grafana alert rule keyed by uid."""
manifest = next(
document
for document in yaml.safe_load_all(
(REPO_ROOT / "services/monitoring/grafana-alerting-config.yaml").read_text()
)
if document
)
groups = yaml.safe_load(manifest["data"]["rules.yaml"])["groups"]
return {rule["uid"]: rule for group in groups for rule in group["rules"]}
def test_availability_panel_never_swaps_measurements() -> None:
"""A stale yearly rollup must render empty, not a 1h ratio in disguise."""
panel = _panel("Atlas Availability")
exprs = _panel_exprs(panel)
assert "atlas:availability:ratio_365d" in exprs
assert "traefik_entrypoint_requests_total" not in exprs
def test_availability_coverage_is_disclosed() -> None:
"""The overview must state how many days the yearly figure rests on."""
panel = _panel("Availability Coverage")
assert "atlas:availability:coverage_days_365d" in _panel_exprs(panel)
def test_stale_availability_rollup_pages() -> None:
"""A silent rollup once hid 34 hours of lost publishes; staleness must page."""
rules = _alert_rules()
stale = rules["atlas-availability-rollup-stale"]
assert "atlas:availability:ratio_365d" in stale["data"][0]["model"]["expr"]
assert stale["noDataState"] == "Alerting"
assert stale["execErrState"] == "Alerting"
assert stale["labels"]["severity"] == "warning"
def test_rollup_proves_its_publish_survived() -> None:
"""A read-only store accepts imports and drops them; the job must read back."""
source = (
REPO_ROOT / "services/monitoring/scripts/availability_rollup.py"
).read_text()
assert "def verify_stored" in source
assert "verify_stored(OUTPUT_METRIC" in source
assert "atlas:availability:coverage_days_365d" in source

View File

@ -43,10 +43,14 @@ def test_calculate_availability_uses_all_server_failures() -> None:
def test_render_metric_publishes_only_the_request_v4_series() -> None:
"""Render the single series selected by the Grafana panel."""
"""Render the two series selected by the Grafana overview panels."""
mod = load_module()
assert mod.render_metric(0.9995, 1234) == (
assert mod.render_metric(mod.OUTPUT_METRIC, 0.9995, 1234) == (
'atlas:availability:ratio_365d{definition="request-v4",scope="atlas",'
'rollup="yearly"} 0.999500000000 1234\n'
)
assert mod.render_metric(mod.COVERAGE_METRIC, 109, 1234) == (
'atlas:availability:coverage_days_365d{definition="request-v4",scope="atlas",'
'rollup="yearly"} 109.000000000000 1234\n'
)

View File

@ -42,16 +42,18 @@ def test_node_filter_and_expr_helpers():
def test_overview_availability_panel_uses_recorded_365d_rollup():
mod = load_module()
dashboard = mod.build_overview()
panel = next(panel for panel in flatten_panels(dashboard["panels"]) if panel["id"] == 27)
panels_by_id = {panel["id"]: panel for panel in flatten_panels(dashboard["panels"])}
panel = panels_by_id[27]
assert panel["title"] == "Atlas Availability (365d)"
assert panel["title"] == "Atlas Availability (365d window)"
availability_expr = panel["targets"][0]["expr"]
assert (
'last_over_time(atlas:availability:ratio_365d{scope="atlas",definition="request-v4"}[48h])'
in availability_expr
availability_expr
== 'last_over_time(atlas:availability:ratio_365d{scope="atlas",definition="request-v4"}[48h])'
)
assert 'code=~"5.."' in availability_expr
assert 'code=~"[1-5].."' in availability_expr
# A stale rollup must render nothing rather than silently substituting the
# last hour of Traefik traffic under a yearly title, as it did on 2026-08-18.
assert "traefik_entrypoint_requests_total" not in availability_expr
assert "atlas:availability:failures_1d" not in availability_expr
assert "atlas:availability:requests_1d" not in availability_expr
assert "sum_over_time" not in availability_expr
@ -59,9 +61,15 @@ def test_overview_availability_panel_uses_recorded_365d_rollup():
assert "kube_deployment_status_replicas_available" not in availability_expr
assert panel["targets"][0]["instant"] is True
assert "Every server-side 5xx" in panel["description"]
assert "Replica counts, Grafana health" in panel["description"]
assert "daily rollup job publishes one annual sample" in panel["description"]
assert "keeps it for up to 48 hours" in panel["description"]
assert "never converted into downtime or uptime" in panel["description"]
coverage = panels_by_id[36]
assert coverage["title"] == "Availability Coverage (days)"
assert (
coverage["targets"][0]["expr"]
== 'last_over_time(atlas:availability:coverage_days_365d{scope="atlas",definition="request-v4"}[48h])'
)
assert "lowers this number instead of moving availability" in coverage["description"]
def test_overview_uses_readable_quality_power_and_gitops_panels():
mod = load_module()

View File

@ -40,6 +40,40 @@ or the WebUI. Browser chat remains available when `bot_token` is empty.
The bot token and relay key must never be added to Git or a Kubernetes Secret.
The router does not log prompt bodies, raw Telegram IDs, link codes, or tokens.
## Private Jetson voice: multilingual TTS policy
`hermes-tts` on `titan-21` bakes three checksum-pinned Piper voices and
selects one per request from a fixed, allow-listed `language` field: `en`/
`en-US` → `en_US-amy-medium`, `ru`/`ru-RU` → `ru_RU-irina-medium`, `es`/
`es-MX`/`es-ES` → `es_MX-claude-high` (Piper's `claude` voice is Mexican
Spanish; there is no Castilian `es_ES-claude`). Matching is case-insensitive
and accepts both `_` and `-` separators. Any language that is missing,
unrecognized, or malformed falls back to English amy rather than erroring.
The mapping is a fixed dict from `language` to one of the three baked model
names only — a client-supplied `voice` field is never read, so no client
input can select or construct a model path. All three voices are preloaded
at process start (see `dockerfiles/hermes-jetson-tts-server.py`).
Hermes Chat deliberately exposes no TTS speaker/model choice. The deterministic
WebUI image patch removes the pinned upstream voice selector, its label and
translations, its browser/server preference persistence, and every outbound
client `voice` field while preserving the TTS engine, speech rate/pitch,
dictation, hands-free Voice Mode, and the conversation instrument. Legacy
`hermes-tts-voice` browser state is deleted. Voice choice is therefore policy,
not a client preference: validated English maps to amy, Russian to irina,
Spanish to claude, and every unsupported or absent language falls back to amy.
The private WebUI voice bridge (`dockerfiles/hermes-webui-atlas-voice.js`,
patched into `api/routes.py` by `hermes-webui-atlas-patch.py`) has no signal
for the language of the assistant reply it is about to speak — it sends only
`text` and `engine`. Until the WebUI or gateway attaches an explicit
`language` field to that request, every reply speaks in the safe English
default regardless of its actual language. Closing that gap needs a language
signal upstream of the TTS call (e.g. tagging the assistant turn with a
detected/declared reply language and threading it through
`hermes-webui-atlas-voice.js` → `api/routes.py` → the `language` field), not
client- or server-side guessing bolted onto the TTS service itself.
## The one-sentence explanation
Hermes is the persistent agent runtime and control surface; Codex or the local
@ -347,6 +381,40 @@ Use this short explanation:
- `Use $tune-atlas-alerts. Trace one currently firing alert to its generated source and raw PromQL, but do not edit it.`
- `Use $master-hermes-on-atlas. Assess me on the request path and permission boundary. One question at a time.`
## Private voice: choosing the Piper voice from the STT-detected language
Hands-free voice mode in `chat.hermes.bstein.dev` selects the private Piper
voice from the language the private Jetson Whisper service reports for the
user's own speech. The signal travels one way only, and every hop narrows it:
1. `hermes-stt` returns `{text, model, language}`. `language` is whatever
Whisper decoded with, accepted only as a bare ISO-639 token (`en`, `ru`,
`es`, `yue`, …); anything else is reported as empty.
2. `hermes_stt_client.py` writes the usual `<stem>.txt` transcript plus a
`<stem>.language` sidecar. The transcript stays the only `.txt` in the
output directory, so the stock Hermes local-command contract is unchanged.
3. The patched local-command STT envelope reads that sidecar and adds
`language` to its result; `/api/transcribe` re-validates it and returns it
next to `transcript`.
4. `atlas-voice.js` keeps that value only for the turn it belongs to. It is
bound to the voice-mode generation token and the chat session id, consumed
exactly once by the reply that turn produced, and cleared on cancellation,
restart, session change, an empty transcript, or a transcription error.
5. `/api/tts` accepts `language` only from the fixed allow-list and otherwise
sends English. The Jetson TTS service applies the same allow-list again as
the final authority.
**What this does not claim.** The language is the language the *user spoke*,
not the language of the reply. A model asked a Russian question may answer in
English and will then be read aloud by the Russian voice, and vice versa; this
is a deliberate policy choice for hands-free mode, not a detection failure.
Nothing here detects the language of assistant text.
**Everything else stays English.** Typed messages, the manual read-aloud
button, and any assistant reply that was not produced by a hands-free spoken
turn carry no trusted STT signal, so they synthesize with `en_US-amy-medium`.
A `voice` field from a browser is never honoured at any hop.
## Honest limits
- Hermes does not currently apply production or cluster changes autonomously.

View File

@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Callable
from cli_lane_board import _board_call
from cli_lane_config import ProcessResult, Route
from cli_lane_config import EFFORTS, ProcessResult, Route
from cli_lane_health import classify_capacity_failure, record_provider_failure
from cli_lane_metrics import (
record_provider_fallback,
@ -117,20 +117,47 @@ def capacity_failover(
+ "\n\nRouting boundary: the first provider failed from capacity/authentication. "
+ "Select the alternate hosted provider at an appropriate effort."
)
alternate = "claude" if route.provider == "codex" else "codex"
fallback = _routed_or_blocked(
kanban_db,
board,
task_id,
run_id,
lambda: select_route(retry_context, f"cli-{alternate}-{route.effort}"),
lambda: select_route(
retry_context,
assignee,
exclude_provider=route.provider,
exclude_reason=f"hit a {failure_class} failure at this boundary",
),
)
if fallback is None:
return FailoverOutcome(route, result, candidate_file, route.provider, True)
if EFFORTS.index(fallback.effort) < EFFORTS.index(route.effort):
# Never let a capacity-triggered reclassification downgrade effort:
# re-pin the classifier's chosen (healthy) provider at the original
# floor, whether the downgrade came from the classifier itself or
# from its own excluded-provider health guard.
preserved = _routed_or_blocked(
kanban_db,
board,
task_id,
run_id,
lambda: select_route(
retry_context, f"cli-{fallback.provider}-{route.effort}"
),
)
if preserved is None:
return FailoverOutcome(route, result, candidate_file, route.provider, True)
comment(
f"Effort preserved: Switchyard classification chose {fallback.effort} "
f"for {fallback.provider}; escalated to the original {route.effort} "
"floor so capacity failover never downgrades a safety task.",
)
fallback = preserved
record_provider_fallback(route.provider, fallback.provider, failure_class)
comment(
f"Provider fallback: {route.provider} -> {fallback.provider} "
f"after a {failure_class} failure; Jetson reclassified the retry boundary.",
f"after a {failure_class} failure; Jetson reclassified the retry boundary "
f"(classifier={fallback.classifier}).",
)
fallback_result = run_provider(
fallback,

View File

@ -12,6 +12,34 @@ from pathlib import Path
from urllib.request import Request, urlopen
LANGUAGE_SUFFIX = ".language"
def _normalize_language(value: object) -> str:
"""Accept only a bare ISO-639 code from the private Whisper response."""
if not isinstance(value, str):
return ""
code = value.strip().lower()
if not 2 <= len(code) <= 3 or not code.isascii() or not code.isalpha():
return ""
return code
def _write_result(output_dir: Path, stem: str, transcript: str, language: str) -> Path:
"""Write the .txt Hermes reads, plus the language sidecar when we have one.
Hermes' local-command contract is "leave a .txt in --output-dir"; it globs
``*.txt`` and reads the first match. The sidecar deliberately uses another
suffix so the transcript stays the only ``.txt`` in the directory.
"""
output_dir.mkdir(parents=True, exist_ok=True)
transcript_path = output_dir / f"{stem}.txt"
transcript_path.write_text(transcript, encoding="utf-8")
if language:
(output_dir / f"{stem}{LANGUAGE_SUFFIX}").write_text(language, encoding="utf-8")
return transcript_path
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
@ -67,9 +95,12 @@ def main() -> None:
with urlopen(request, timeout=120) as response:
result = json.loads(response.read().decode("utf-8"))
transcript = str(result.get("text") or "").strip()
args.output_dir.mkdir(parents=True, exist_ok=True)
output = args.output_dir / f"{args.input_path.stem}.txt"
output.write_text(transcript, encoding="utf-8")
_write_result(
args.output_dir,
args.input_path.stem,
transcript,
_normalize_language(result.get("language")),
)
if __name__ == "__main__":

View File

@ -17,7 +17,7 @@
},
"gridPos": {
"h": 5,
"w": 4,
"w": 3,
"x": 0,
"y": 0
},
@ -72,7 +72,7 @@
"gridPos": {
"h": 5,
"w": 3,
"x": 4,
"x": 3,
"y": 0
},
"targets": [
@ -148,7 +148,7 @@
"gridPos": {
"h": 5,
"w": 3,
"x": 7,
"x": 6,
"y": 0
},
"targets": [
@ -216,20 +216,20 @@
{
"id": 27,
"type": "stat",
"title": "Atlas Availability (365d)",
"title": "Atlas Availability (365d window)",
"datasource": {
"type": "prometheus",
"uid": "atlas-vm"
},
"gridPos": {
"h": 5,
"w": 4,
"x": 10,
"w": 3,
"x": 9,
"y": 0
},
"targets": [
{
"expr": "(last_over_time(atlas:availability:ratio_365d{scope=\"atlas\",definition=\"request-v4\"}[48h]) or on() (1 - ((sum(increase(traefik_entrypoint_requests_total{entrypoint=\"websecure\",protocol=\"http\",code=~\"5..\"}[1h])) or on() vector(0)) / clamp_min(sum(increase(traefik_entrypoint_requests_total{entrypoint=\"websecure\",protocol=\"http\",code=~\"[1-5]..\"}[1h])), 1))))",
"expr": "last_over_time(atlas:availability:ratio_365d{scope=\"atlas\",definition=\"request-v4\"}[48h])",
"refId": "A",
"instant": true
}
@ -286,7 +286,78 @@
},
"textMode": "value"
},
"description": "Rolling request-weighted availability at the Atlas HTTPS ingress: responses below 500 divided by all HTTP responses. Every server-side 5xx is a real failed request; client 4xx responses count as served. Replica counts, Grafana health, and monitoring gaps are not inputs, so they cannot lower availability unless user traffic actually receives a 5xx. A daily rollup job publishes one annual sample from deduplicated daily totals; Grafana keeps it for up to 48 hours so one delayed retry cannot cause a fallback, and only uses the same one-hour request SLI before history exists."
"description": "Request-weighted availability at the Atlas HTTPS ingress over the trailing year: responses below 500 divided by all HTTP responses, read from the daily rollup sample. Every server-side 5xx is a real failed request; client 4xx responses count as served. Only measured days enter the ratio: a telemetry gap such as the 2026-08-18 metrics-storage outage is excluded from both sides, never converted into downtime or uptime, and the Availability Coverage panel states how many days the figure actually rests on. If the rollup stops publishing, this panel goes stale and renders no value instead of quietly substituting a one-hour live ratio under a yearly title; the atlas-availability-rollup-stale alert pages first."
},
{
"id": 36,
"type": "stat",
"title": "Availability Coverage (days)",
"datasource": {
"type": "prometheus",
"uid": "atlas-vm"
},
"gridPos": {
"h": 5,
"w": 3,
"x": 12,
"y": 0
},
"targets": [
{
"expr": "last_over_time(atlas:availability:coverage_days_365d{scope=\"atlas\",definition=\"request-v4\"}[48h])",
"refId": "A",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "dark-red",
"value": null
},
{
"color": "dark-orange",
"value": 7
},
{
"color": "dark-yellow",
"value": 30
},
{
"color": "dark-green",
"value": 90
}
]
},
"unit": "none",
"custom": {
"displayMode": "auto"
},
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "center",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "value"
},
"description": "Days of daily rollups the availability figure to the left actually rests on, out of the 365-day window. A telemetry gap such as the 2026-08-18 metrics-storage outage lowers this number instead of moving availability, which is what keeps an observability failure from being reported as Atlas downtime. The request-v4 definition begins on 2026-05-01, so coverage stays below a full year until that history accumulates."
},
{
"id": 4,
@ -299,7 +370,7 @@
"gridPos": {
"h": 5,
"w": 3,
"x": 14,
"x": 15,
"y": 0
},
"targets": [
@ -375,7 +446,7 @@
"gridPos": {
"h": 5,
"w": 3,
"x": 17,
"x": 18,
"y": 0
},
"targets": [
@ -450,8 +521,8 @@
},
"gridPos": {
"h": 5,
"w": 4,
"x": 20,
"w": 3,
"x": 21,
"y": 0
},
"targets": [

View File

@ -398,6 +398,58 @@ data:
summary: "VictoriaMetrics stored no samples for 15m; every dashboard is about to read empty"
labels:
severity: critical
# The rollup CronJob publishes one yearly availability sample per
# day, and the Overview panel refuses to substitute another
# measurement when that sample goes stale. Staleness therefore has
# to page before the panel goes blank at the 48h lookback.
- uid: atlas-availability-rollup-stale
title: "Atlas availability rollup is stale (>26h)"
condition: C
for: "30m"
data:
- refId: A
relativeTimeRange:
from: 600
to: 0
datasourceUid: atlas-vm
model:
intervalMs: 60000
maxDataPoints: 43200
expr: (time() - tlast_over_time(atlas:availability:ratio_365d{scope="atlas",definition="request-v4"}[3d])) or on() vector(999999)
legendFormat: seconds since publish
datasource:
type: prometheus
uid: atlas-vm
- refId: B
datasourceUid: __expr__
model:
expression: A
intervalMs: 60000
maxDataPoints: 43200
reducer: last
type: reduce
- refId: C
datasourceUid: __expr__
model:
expression: B
intervalMs: 60000
maxDataPoints: 43200
type: threshold
conditions:
- evaluator:
params: [93600]
type: gt
operator:
type: and
reducer:
type: last
type: query
noDataState: Alerting
execErrState: Alerting
annotations:
summary: "Atlas availability rollup has not published in >26h; the Overview availability panel goes blank at 48h"
labels:
severity: warning
- orgId: 1
name: maintenance
folder: Alerts

View File

@ -26,7 +26,7 @@ data:
},
"gridPos": {
"h": 5,
"w": 4,
"w": 3,
"x": 0,
"y": 0
},
@ -81,7 +81,7 @@ data:
"gridPos": {
"h": 5,
"w": 3,
"x": 4,
"x": 3,
"y": 0
},
"targets": [
@ -157,7 +157,7 @@ data:
"gridPos": {
"h": 5,
"w": 3,
"x": 7,
"x": 6,
"y": 0
},
"targets": [
@ -225,20 +225,20 @@ data:
{
"id": 27,
"type": "stat",
"title": "Atlas Availability (365d)",
"title": "Atlas Availability (365d window)",
"datasource": {
"type": "prometheus",
"uid": "atlas-vm"
},
"gridPos": {
"h": 5,
"w": 4,
"x": 10,
"w": 3,
"x": 9,
"y": 0
},
"targets": [
{
"expr": "(last_over_time(atlas:availability:ratio_365d{scope=\"atlas\",definition=\"request-v4\"}[48h]) or on() (1 - ((sum(increase(traefik_entrypoint_requests_total{entrypoint=\"websecure\",protocol=\"http\",code=~\"5..\"}[1h])) or on() vector(0)) / clamp_min(sum(increase(traefik_entrypoint_requests_total{entrypoint=\"websecure\",protocol=\"http\",code=~\"[1-5]..\"}[1h])), 1))))",
"expr": "last_over_time(atlas:availability:ratio_365d{scope=\"atlas\",definition=\"request-v4\"}[48h])",
"refId": "A",
"instant": true
}
@ -295,7 +295,78 @@ data:
},
"textMode": "value"
},
"description": "Rolling request-weighted availability at the Atlas HTTPS ingress: responses below 500 divided by all HTTP responses. Every server-side 5xx is a real failed request; client 4xx responses count as served. Replica counts, Grafana health, and monitoring gaps are not inputs, so they cannot lower availability unless user traffic actually receives a 5xx. A daily rollup job publishes one annual sample from deduplicated daily totals; Grafana keeps it for up to 48 hours so one delayed retry cannot cause a fallback, and only uses the same one-hour request SLI before history exists."
"description": "Request-weighted availability at the Atlas HTTPS ingress over the trailing year: responses below 500 divided by all HTTP responses, read from the daily rollup sample. Every server-side 5xx is a real failed request; client 4xx responses count as served. Only measured days enter the ratio: a telemetry gap such as the 2026-08-18 metrics-storage outage is excluded from both sides, never converted into downtime or uptime, and the Availability Coverage panel states how many days the figure actually rests on. If the rollup stops publishing, this panel goes stale and renders no value instead of quietly substituting a one-hour live ratio under a yearly title; the atlas-availability-rollup-stale alert pages first."
},
{
"id": 36,
"type": "stat",
"title": "Availability Coverage (days)",
"datasource": {
"type": "prometheus",
"uid": "atlas-vm"
},
"gridPos": {
"h": 5,
"w": 3,
"x": 12,
"y": 0
},
"targets": [
{
"expr": "last_over_time(atlas:availability:coverage_days_365d{scope=\"atlas\",definition=\"request-v4\"}[48h])",
"refId": "A",
"instant": true
}
],
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "dark-red",
"value": null
},
{
"color": "dark-orange",
"value": 7
},
{
"color": "dark-yellow",
"value": 30
},
{
"color": "dark-green",
"value": 90
}
]
},
"unit": "none",
"custom": {
"displayMode": "auto"
},
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "center",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "value"
},
"description": "Days of daily rollups the availability figure to the left actually rests on, out of the 365-day window. A telemetry gap such as the 2026-08-18 metrics-storage outage lowers this number instead of moving availability, which is what keeps an observability failure from being reported as Atlas downtime. The request-v4 definition begins on 2026-05-01, so coverage stays below a full year until that history accumulates."
},
{
"id": 4,
@ -308,7 +379,7 @@ data:
"gridPos": {
"h": 5,
"w": 3,
"x": 14,
"x": 15,
"y": 0
},
"targets": [
@ -384,7 +455,7 @@ data:
"gridPos": {
"h": 5,
"w": 3,
"x": 17,
"x": 18,
"y": 0
},
"targets": [
@ -459,8 +530,8 @@ data:
},
"gridPos": {
"h": 5,
"w": 4,
"x": 20,
"w": 3,
"x": 21,
"y": 0
},
"targets": [

View File

@ -20,7 +20,12 @@ DEFINITION = "request-v4"
REQUESTS_METRIC = "atlas:availability:requests_1d"
FAILURES_METRIC = "atlas:availability:failures_1d"
OUTPUT_METRIC = "atlas:availability:ratio_365d"
COVERAGE_METRIC = "atlas:availability:coverage_days_365d"
WINDOW_DAYS = 365
# Freshly imported samples sit in the in-memory buffer briefly before they
# become searchable, so the read-back check retries instead of failing fast.
VERIFY_ATTEMPTS = 6
VERIFY_DELAY_SECONDS = 10
def parse_export(lines: Iterable[bytes]) -> dict[int, float]:
@ -35,8 +40,8 @@ def parse_export(lines: Iterable[bytes]) -> dict[int, float]:
return points
def fetch_rollup(metric: str, start: datetime, end: datetime) -> dict[int, float]:
"""Stream one compact rollup series from VictoriaMetrics."""
def fetch_series(metric: str, start: datetime, end: datetime) -> dict[int, float]:
"""Stream one compact series from VictoriaMetrics."""
matcher = (
f'{{__name__="{metric}",scope="{SCOPE}",definition="{DEFINITION}"}}'
)
@ -60,19 +65,19 @@ def calculate_availability(requests: float, failures: float) -> float:
return max(0.0, min(1.0, 1.0 - (failures / requests)))
def render_metric(value: float, timestamp_ms: int) -> str:
def render_metric(metric: str, value: float, timestamp_ms: int) -> str:
"""Render one VictoriaMetrics Prometheus-import sample."""
return (
f'{OUTPUT_METRIC}{{definition="{DEFINITION}",scope="{SCOPE}",'
f'{metric}{{definition="{DEFINITION}",scope="{SCOPE}",'
f'rollup="yearly"}} {value:.12f} {timestamp_ms}\n'
)
def publish(value: float, timestamp_ms: int) -> None:
"""Write the calculated annual ratio to VictoriaMetrics."""
def publish(metric: str, value: float, timestamp_ms: int) -> None:
"""Write one calculated yearly sample to VictoriaMetrics."""
request = Request(
f"{VM_URL}/api/v1/import/prometheus",
data=render_metric(value, timestamp_ms).encode(),
data=render_metric(metric, value, timestamp_ms).encode(),
headers={"Content-Type": "text/plain"},
method="POST",
)
@ -81,21 +86,53 @@ def publish(value: float, timestamp_ms: int) -> None:
raise RuntimeError(f"VictoriaMetrics import returned HTTP {response.status}")
def verify_stored(metric: str, value: float, timestamp_ms: int) -> None:
"""Prove the sample landed; a read-only store accepts writes and drops them.
During the 2026-08-18 storage outage every import returned success while
VictoriaMetrics silently discarded the samples, so this job reported
healthy runs across a day and a half of lost publishes. Reading the sample
back is the only evidence the write survived.
"""
when = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
for attempt in range(VERIFY_ATTEMPTS):
if attempt:
time.sleep(VERIFY_DELAY_SECONDS)
points = fetch_series(
metric, when - timedelta(minutes=5), when + timedelta(minutes=5)
)
stored = points.get(timestamp_ms)
if stored is not None and abs(stored - value) < 1e-9:
return
raise RuntimeError(
f"{metric} sample at {timestamp_ms} is not readable after publish; "
"VictoriaMetrics accepted and then discarded the write "
"(is /storage read-only?)"
)
def main() -> None:
"""Rebuild and publish the rolling request-availability sample."""
"""Rebuild and publish the rolling request-availability samples."""
end = datetime.now(timezone.utc)
start = end - timedelta(days=WINDOW_DAYS)
requests = sum(fetch_rollup(REQUESTS_METRIC, start, end).values())
failures = sum(fetch_rollup(FAILURES_METRIC, start, end).values())
availability = calculate_availability(requests, failures)
requests = fetch_series(REQUESTS_METRIC, start, end)
failures = fetch_series(FAILURES_METRIC, start, end)
availability = calculate_availability(
sum(requests.values()), sum(failures.values())
)
coverage_days = float(len(requests))
timestamp_ms = time.time_ns() // 1_000_000
publish(availability, timestamp_ms)
publish(OUTPUT_METRIC, availability, timestamp_ms)
publish(COVERAGE_METRIC, coverage_days, timestamp_ms)
verify_stored(OUTPUT_METRIC, availability, timestamp_ms)
verify_stored(COVERAGE_METRIC, coverage_days, timestamp_ms)
print(
json.dumps(
{
"requests": requests,
"failures": failures,
"availability_percent": availability * 100,
"coverage_days": coverage_days,
"failures": sum(failures.values()),
"requests": sum(requests.values()),
"timestamp_ms": timestamp_ms,
},
sort_keys=True,

View File

@ -0,0 +1,12 @@
# Hermes Agent STT fixture provenance
This minimal module contains the exact `_prepare_local_audio` patch context
extracted from the agent image inherited by `Dockerfile.hermes-webui`:
- Image: `registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107`
- OCI platform manifest: `sha256:9b4c00a25fd08f0df3bcb800d755bd48576fb65af5ab0df40058fbb78a147e43`
- Upstream Hermes revision: `9de9c25f620ff7f1ce0fd5457d596052d5159596`
- Full upstream `tools/transcription_tools.py` SHA-256: `d7d7df56b98dfadc0a7c08d5addd02db4e3106e6fd3b753e9241c23145eb2951`
Tests execute the production patcher against this source fragment and then run
its real ffmpeg conversion boundary with browser-produced bytes.

View File

@ -0,0 +1,44 @@
"""Exact pinned local-audio preparation fragment with its direct dependencies."""
import os
from pathlib import Path
import shutil
import subprocess
from typing import Optional
LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"}
logger = __import__("logging").getLogger(__name__)
def _find_ffmpeg_binary() -> Optional[str]:
return shutil.which("ffmpeg")
def windows_hide_flags() -> int:
return 0
def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str], Optional[str]]:
"""Normalize audio for local CLI STT when needed."""
audio_path = Path(file_path)
if audio_path.suffix.lower() in LOCAL_NATIVE_AUDIO_FORMATS:
return file_path, None
ffmpeg = _find_ffmpeg_binary()
if not ffmpeg:
return None, "Local STT fallback requires ffmpeg for non-WAV inputs, but ffmpeg was not found"
converted_path = os.path.join(work_dir, f"{audio_path.stem}.wav")
command = [ffmpeg, "-y", "-i", file_path, converted_path]
try:
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
return converted_path, None
except subprocess.TimeoutExpired:
logger.error("ffmpeg conversion timed out for %s", file_path)
return None, "Audio conversion for local STT timed out"
except subprocess.CalledProcessError as e:
details = e.stderr.strip() or e.stdout.strip() or str(e)
logger.error("ffmpeg conversion failed for %s: %s", file_path, details)
return None, f"Failed to convert audio for local STT: {details}"

View File

@ -0,0 +1,28 @@
"""Pinned local-command STT envelope anchor used by image-patch tests."""
import contextlib
from pathlib import Path
class _Logger:
def info(self, *args):
return None
logger = _Logger()
def _transcribe_local_command(file_path, normalized_model, output_dir):
try:
with contextlib.nullcontext(output_dir):
txt_files = sorted(Path(output_dir).glob("*.txt"))
transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
logger.info(
"Transcribed %s via local STT command (%s, %d chars)",
Path(file_path).name,
normalized_model,
len(transcript_text),
)
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
except OSError as error:
return {"success": False, "transcript": "", "error": str(error)}

View File

@ -0,0 +1,10 @@
_SETTINGS_DEFAULTS = {
"tts_voice": "",
}
_SETTINGS_SPEECH_KEYS = {
"tts_voice",
}
UPSTREAM_VALIDATION_FRAGMENT = ''' if k == "tts_voice":
if not isinstance(v, str) or len(v) > 200 or "\x00" in v:
continue
'''

View File

@ -1,3 +1,41 @@
def tts(handler, engine):
"""Pinned Atlas TTS route anchors used by image-patch tests."""
import json
import os
from urllib.request import ProxyHandler, Request, build_opener
class _NoRedirectTtsHandler:
"""Placeholder for the upstream no-redirect opener handler."""
class _Logger:
def exception(self, message):
return None
logger = _Logger()
class _Upstream:
def read(self):
return b"RIFFsynthetic"
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def _buffer_tts_audio_response(response):
return response.read()
def _tts_open(req, *, timeout=30, opener_factory=None):
return _Upstream()
def _handle_tts(handler, data, text, rate_str, engine):
# ── ElevenLabs TTS ──────────────────────────────────────────────────
return False
return None

View File

@ -0,0 +1,13 @@
"""Pinned upstream /api/transcribe response anchor used by image-patch tests."""
def j(handler, payload, status=200):
return {"status": status, "payload": payload}
def handle_transcribe(handler, result):
try:
transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
except ValueError as error:
return j(handler, {'error': str(error)}, status=400)

View File

@ -0,0 +1,34 @@
function speakWithRegisteredEngine(){
const _opts={
voice: localStorage.getItem("hermes-tts-voice")||'',
rate: parseFloat(localStorage.getItem("hermes-tts-rate")),
};
return _opts;
}
function speakWithEdge(clean){
const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
const rate='';
const pitch='';
return fetch('/api/tts', {
body: JSON.stringify({text: clean, voice, rate, pitch})
});
}
function speakWithBrowser(clean){
const utter=new SpeechSynthesisUtterance(clean);
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
return utter;
}
function _mirrorSpeechSettingsFromServer(s){
const defaults={
tts_voice:'',
};
[
['tts_voice','hermes-tts-voice'],
].forEach(([settingKey,storageKey])=>localStorage.setItem(storageKey,s[settingKey]));
return defaults;
}

View File

@ -0,0 +1,4 @@
const EN = {
settings_label_tts_voice: 'Voice',
settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
};

View File

@ -5,6 +5,12 @@
</head>
<body>
<select id="settingsTtsEngine"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select>
<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="">Default system voice</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
</div>
<div class="composer-box" id="composerBox">
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>

View File

@ -0,0 +1,44 @@
const _SETTINGS_SPEECH_STORAGE_KEYS={
tts_engine:'hermes-tts-engine',
tts_voice:'hermes-tts-voice',
tts_rate:'hermes-tts-rate',
};
let _settingsSpeechChangedKeys=new Set();
function _speechPreferencesPayloadFromUi(){
const payload={};
const ttsVoiceSel=$('settingsTtsVoice');
if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
return payload;
}
function loadSettingsPanel(){
const ttsEngineSel=$('settingsTtsEngine');
if(ttsEngineSel){
ttsEngineSel.onchange=function(){
localStorage.setItem('hermes-tts-engine',this.value);
window._populateTtsVoices();
_schedulePreferencesAutosave();
};
}
// Populate voice selector based on engine
const ttsVoiceSel=$('settingsTtsVoice');
window._populateTtsVoices=function(){
if(!ttsVoiceSel) return;
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
const current=String(_speechSetting('tts_voice','hermes-tts-voice','')||'');
_syncSpeechPreferenceCache('tts_voice',current);
if(engine==='edge'){
const edgeVoices=[
{value:'en-US-AriaNeural',label:'Aria (English, Female)'},
];
ttsVoiceSel.innerHTML='<option value="">Default (Xiaoxiao)</option>';
edgeVoices.forEach(v=>ttsVoiceSel.appendChild(v));
}
};
if(ttsVoiceSel&&'speechSynthesis' in window){
window._populateTtsVoices();
ttsVoiceSel.onchange=function(){_markSpeechPreferenceChanged('tts_voice');localStorage.setItem('hermes-tts-voice',this.value);_schedulePreferencesAutosave();};
}
// TTS rate/pitch sliders
}

View File

@ -1,4 +1,15 @@
function _buildBrowserUtterance(text, btn){
const utter=new SpeechSynthesisUtterance(text);
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
return utter;
}
function _playEdgeTtsChunked(text, btn){
const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
return fetch('/api/tts',{body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
}
function speakSelected(clean, btn, engine){
@ -11,3 +22,14 @@ function speakAutomatically(clean, engine){
_playEdgeTtsChunked(clean, null);
}
}
function registeredTts(engine, clean){
const _opts={
voice: localStorage.getItem('hermes-tts-voice')||'',
rate: parseFloat(localStorage.getItem('hermes-tts-rate')),
};
const autoOpts={
voice: localStorage.getItem('hermes-tts-voice')||'',
pitch: parseFloat(localStorage.getItem('hermes-tts-pitch')),
};
return [engine, clean, _opts, autoOpts];
}

View File

@ -0,0 +1,18 @@
# Chromium MediaRecorder fixture provenance
`chromium-webm-opus.webm` is the concatenation of ten successive 250 ms-ish
data blobs emitted by a real Chromium `MediaRecorder` for
`audio/webm;codecs=opus`; `chromium-webm-opus.json` records their exact byte
lengths so tests can replay each browser event. The recording has leading
silence followed by a 440 Hz signal, mirroring speech that begins after the
hands-free VAD pre-roll is full.
- Browser: Chromium 140.0.7339.16 (Playwright build v1187, arm64)
- Generator: `testing/probes/generate_mediarecorder_fixture.js`
- Timeslice request: 250 ms
- Complete concatenated payload: WebM/Opus, accepted by ffmpeg 7.1.5
- Complete payload SHA-256: `5585014ecad185511a727ccf47aa8b1ac70ea6f3efd7778309742bf88e6faf51`
- First five chunks are treated as pre-speech by the test model; the production
pre-roll retains only the most recent three.
The fixture is generated data, not recorded speech.

View File

@ -0,0 +1,20 @@
{
"mime_type": "audio/webm;codecs=opus",
"timeslice_ms": 250,
"pre_speech_chunk_count": 5,
"chunk_sizes": [
204,
70,
70,
70,
70,
3864,
5796,
4830,
4830,
1945
],
"payload_file": "chromium-webm-opus.webm",
"browser": "140.0.7339.16",
"generator": "testing/probes/generate_mediarecorder_fixture.js"
}

Binary file not shown.

View File

@ -0,0 +1,84 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
async function main() {
const outputPath = process.argv[2];
if (!outputPath) throw new Error('usage: node generate_mediarecorder_fixture.js OUTPUT.json');
const browser = await chromium.launch({
headless: true,
args: ['--autoplay-policy=no-user-gesture-required'],
});
try {
const page = await browser.newPage();
const fixture = await page.evaluate(async () => {
const mimeType = 'audio/webm;codecs=opus';
if (!MediaRecorder.isTypeSupported(mimeType)) {
throw new Error(`${mimeType} is not supported by this Chromium build`);
}
const context = new AudioContext({sampleRate: 48000});
const destination = context.createMediaStreamDestination();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 440;
gain.gain.value = 0;
oscillator.connect(gain).connect(destination);
oscillator.start();
const chunks = [];
const recorder = new MediaRecorder(destination.stream, {mimeType});
recorder.ondataavailable = event => {
if (event.data && event.data.size) chunks.push(event.data);
};
const stopped = new Promise(resolve => { recorder.onstop = resolve; });
const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
recorder.start(250);
await delay(1400);
gain.gain.setValueAtTime(0.35, context.currentTime);
await delay(900);
gain.gain.setValueAtTime(0, context.currentTime);
await delay(500);
recorder.stop();
await stopped;
oscillator.stop();
await context.close();
const encoded = [];
for (const chunk of chunks) {
const bytes = new Uint8Array(await chunk.arrayBuffer());
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
encoded.push(btoa(binary));
}
return {
mime_type: recorder.mimeType,
timeslice_ms: 250,
pre_speech_chunk_count: 5,
chunks_base64: encoded,
};
});
const chunks = fixture.chunks_base64.map(value => Buffer.from(value, 'base64'));
const payloadPath = outputPath.replace(/\.json$/i, '.webm');
fixture.chunk_sizes = chunks.map(chunk => chunk.length);
fixture.payload_file = path.basename(payloadPath);
delete fixture.chunks_base64;
fixture.browser = await browser.version();
fixture.generator = 'testing/probes/generate_mediarecorder_fixture.js';
fs.writeFileSync(payloadPath, Buffer.concat(chunks));
fs.writeFileSync(outputPath, `${JSON.stringify(fixture, null, 2)}\n`);
} finally {
await browser.close();
}
}
main().catch(error => {
console.error(error.stack || error);
process.exitCode = 1;
});

View File

@ -2,6 +2,7 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
class StyleDeclaration {
@ -92,6 +93,7 @@ async function boot(scriptPath, reduced) {
const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}};
let rejectNextCapture = false;
let transcriptResolve;
let uploadedFile = null;
let sent = 0;
let lastAudio = null;
@ -117,9 +119,10 @@ async function boot(scriptPath, reduced) {
}
class FakeMediaRecorder {
static isTypeSupported() { return true; }
constructor(stream) {
constructor(stream, options) {
this.stream = stream;
this.state = 'inactive';
this.mimeType = options?.mimeType || 'audio/webm;codecs=opus';
this.ondataavailable = null;
this.onstop = null;
recorders.push(this);
@ -171,11 +174,18 @@ async function boot(scriptPath, reduced) {
},
},
};
async function fetch(url) {
async function fetch(url, options) {
if (url === '/api/transcribe/capability') {
return {ok: true, json: async () => ({available: true, provider: 'local_command'})};
}
if (url === '/api/transcribe') {
const file = options?.body?.get('file');
assert.ok(file, 'transcription request did not carry a file');
uploadedFile = {
bytes: new Uint8Array(await file.arrayBuffer()),
name: file.name,
type: file.type,
};
return new Promise(resolve => { transcriptResolve = resolve; });
}
if (url === '/api/tts') {
@ -245,6 +255,7 @@ async function boot(scriptPath, reduced) {
captures, recorders, analysers, assistantRows, toasts, reducedMotion,
get sent() { return sent; },
get lastAudio() { return lastAudio; },
get uploadedFile() { return uploadedFile; },
set now(value) { now = value; },
rejectCapture() { rejectNextCapture = true; },
resolveTranscript(payload) {
@ -261,7 +272,7 @@ async function boot(scriptPath, reduced) {
};
}
async function normalMotionContract(scriptPath) {
async function normalMotionContract(scriptPath, mediaFixture) {
const probe = await boot(scriptPath, false);
const originalStyleLink = probe.styleLink;
@ -272,17 +283,38 @@ async function normalMotionContract(scriptPath) {
await flush();
assert.equal(probe.captures.length, 1);
const mediaChunks = mediaFixture.chunks;
const preSpeechCount = mediaFixture.pre_speech_chunk_count;
for (const chunk of mediaChunks.slice(0, preSpeechCount)) {
probe.recorders[0].ondataavailable({
data: new Blob([chunk], {type: mediaFixture.mime_type}),
});
}
probe.analysers[0].level = 0.3;
probe.runIntervals();
probe.runIntervals();
probe.runIntervals();
probe.recorders[0].ondataavailable({data: new Blob(['speech'], {type: 'audio/webm'})});
for (const chunk of mediaChunks.slice(preSpeechCount)) {
probe.recorders[0].ondataavailable({
data: new Blob([chunk], {type: mediaFixture.mime_type}),
});
}
probe.now = 4000;
probe.analysers[0].level = 0;
probe.runIntervals();
await flush();
await flush();
assert.match(probe.indicator.className, /\btranscribing\b/);
assert.equal(probe.label.textContent, 'Transcribing…');
assert.ok(probe.uploadedFile, 'transcription upload was not captured');
assert.equal(probe.uploadedFile.name, 'voice-input.webm');
assert.equal(probe.uploadedFile.type, mediaFixture.mime_type);
const expectedUpload = Buffer.concat([
mediaChunks[0],
...mediaChunks.slice(preSpeechCount - 3),
]);
assert.deepEqual(Buffer.from(probe.uploadedFile.bytes), expectedUpload);
probe.resolveTranscript({transcript: 'Hello Hermes'});
await flush();
@ -310,8 +342,21 @@ async function normalMotionContract(scriptPath) {
async function main() {
const scriptPath = process.argv[2];
assert.ok(scriptPath, 'usage: node hermes_voice_instrument_probe.js <atlas-voice.js>');
const first = await normalMotionContract(scriptPath);
const fixturePath = process.argv[3];
assert.ok(
scriptPath && fixturePath,
'usage: node hermes_voice_instrument_probe.js <atlas-voice.js> <media-fixture.json>',
);
const mediaFixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const payload = fs.readFileSync(path.join(path.dirname(fixturePath), mediaFixture.payload_file));
let offset = 0;
mediaFixture.chunks = mediaFixture.chunk_sizes.map(size => {
const chunk = payload.subarray(offset, offset + size);
offset += size;
return chunk;
});
assert.equal(offset, payload.length, 'fixture chunk sizes do not cover the payload');
const first = await normalMotionContract(scriptPath, mediaFixture);
const {probe, originalStyleLink, capturesBeforeSpeech} = first;
assert.equal(probe.captures.length, capturesBeforeSpeech);

View File

@ -0,0 +1,463 @@
// Deterministic browser stub that drives dockerfiles/hermes-webui-atlas-voice.js
// through complete hands-free turns with no microphone, audio device or GPU.
//
// The script under test is an IIFE with no exported seams, so the only honest
// way to assert what reaches POST /api/tts is to run it against a fake DOM and
// fake clock and record the requests it actually makes. Usage:
//
// node atlas_voice_language_probe.js <path-to-atlas-voice.js>
//
// It prints one JSON object describing every scenario to stdout.
'use strict';
const fs = require('fs');
const vm = require('vm');
const SCRIPT_PATH = process.argv[2];
if (!SCRIPT_PATH) {
throw new Error('usage: atlas_voice_language_probe.js <atlas-voice.js>');
}
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
function flush() {
// Four macrotask hops drain the promise chains the script builds around
// fetch()/json()/blob()/play() without ever waiting on wall-clock time.
return new Promise((resolve) => {
let hops = 0;
(function hop() {
hops += 1;
if (hops > 12) {
resolve();
return;
}
setImmediate(hop);
})();
});
}
function makeElement(id) {
return {
id,
style: {
values: new Map(),
setProperty(name, value) { this.values.set(name, String(value)); },
removeProperty(name) { this.values.delete(name); },
getPropertyValue(name) { return this.values.get(name) || ''; },
},
dataset: {},
attributes: {},
value: '',
textContent: '',
className: '',
classList: {
entries: new Set(),
add(name) { this.entries.add(name); },
remove(name) { this.entries.delete(name); },
contains(name) { return this.entries.has(name); },
},
listeners: [],
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name)
? this.attributes[name] : null;
},
addEventListener(type, handler) { this.listeners.push({ type, handler }); },
removeEventListener(type, handler) {
this.listeners = this.listeners.filter((entry) => entry.handler !== handler);
},
click() {
const event = { preventDefault() {}, stopImmediatePropagation() {} };
this.listeners
.filter((entry) => entry.type === 'click')
.forEach((entry) => entry.handler(event));
},
querySelector() { return null; },
insertBefore() {},
appendChild() {},
};
}
function makeHarness() {
const clock = { now: 1000000 };
const timeouts = [];
const intervals = new Map();
let timerId = 1;
const ttsRequests = [];
const transcribeCalls = [];
const toasts = [];
const sends = [];
let capability = { ok: true, available: true, provider: 'local_command' };
let transcribeResponse = { ok: true, transcript: 'hello', language: 'en' };
let transcribeStatus = 200;
let assistantRows = [];
let loud = false;
let recorder = null;
const storage = new Map();
const elements = {};
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
.forEach((id) => { elements[id] = makeElement(id); });
function MediaRecorder() {
this.state = 'recording';
this.ondataavailable = null;
this.onstop = null;
recorder = this;
}
MediaRecorder.prototype.start = function start() { this.state = 'recording'; };
MediaRecorder.prototype.stop = function stop() {
if (this.state === 'inactive') return;
this.state = 'inactive';
if (this.onstop) this.onstop();
};
MediaRecorder.isTypeSupported = function isTypeSupported() { return true; };
function AudioContext() {
this.createAnalyser = () => ({
fftSize: 2048,
getByteTimeDomainData(samples) {
for (let i = 0; i < samples.length; i += 1) {
samples[i] = loud ? (i % 2 ? 200 : 56) : 128;
}
},
});
this.createBiquadFilter = () => ({
type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {},
});
this.createMediaStreamSource = () => ({ connect() {} });
this.close = () => {};
}
function AudioElement() {
this.currentTime = 0;
this.onended = null;
this.onerror = null;
this.pause = () => {};
this.play = () => {
setImmediate(() => { if (this.onended) this.onended(); });
return Promise.resolve();
};
}
async function fetchStub(url, init) {
if (url === '/api/transcribe/capability') {
return { ok: true, status: 200, json: async () => capability };
}
if (url === '/api/transcribe') {
transcribeCalls.push({ body: init && init.body });
return {
ok: transcribeStatus < 400,
status: transcribeStatus,
json: async () => transcribeResponse,
};
}
if (url === '/api/tts') {
ttsRequests.push(JSON.parse(init.body));
return {
ok: true,
status: 200,
blob: async () => ({ synthetic: true }),
json: async () => ({}),
};
}
throw new Error(`unexpected fetch: ${url}`);
}
const context = {
console,
Uint8Array,
Promise,
Math,
JSON,
String,
Number,
Error,
parseInt,
isNaN,
Set,
Map,
Array,
Object,
Date: { now: () => clock.now },
Blob: function Blob(parts, options) { this.parts = parts; this.type = (options || {}).type || ''; },
File: function File(parts, name, options) {
this.parts = parts; this.name = name; this.type = (options || {}).type || '';
},
FormData: function FormData() { this.entries = []; this.append = (k, v) => this.entries.push([k, v]); },
URL: { createObjectURL: () => 'blob:atlas-test', revokeObjectURL() {} },
Audio: AudioElement,
MediaRecorder,
AudioContext,
fetch: fetchStub,
localStorage: {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => { storage.set(key, String(value)); },
removeItem: (key) => { storage.delete(key); },
},
navigator: {
mediaDevices: {
getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }),
getSupportedConstraints: () => ({}),
},
},
document: {
getElementById: (id) => elements[id] || null,
querySelectorAll: () => assistantRows,
createElement: () => ({ value: '', textContent: '' }),
},
S: { session: { session_id: 'session-1' }, busy: false },
setTimeout: (fn, delay) => {
const id = timerId; timerId += 1;
timeouts.push({ id, fn, at: clock.now + (delay || 0) });
return id;
},
clearTimeout: (id) => {
const index = timeouts.findIndex((entry) => entry.id === id);
if (index >= 0) timeouts.splice(index, 1);
},
setInterval: (fn, delay) => {
const id = timerId; timerId += 1;
intervals.set(id, { fn, delay: delay || 0 });
return id;
},
clearInterval: (id) => { intervals.delete(id); },
};
context.window = context;
context.showToast = (message) => { toasts.push(message); };
context.send = () => { sends.push(elements.msg.value); };
context.autoResize = () => {};
vm.createContext(context);
vm.runInContext(SOURCE, context, { filename: 'atlas-voice.js' });
function runDueTimeouts() {
const due = timeouts.filter((entry) => entry.at <= clock.now);
due.forEach((entry) => {
const index = timeouts.indexOf(entry);
if (index >= 0) timeouts.splice(index, 1);
entry.fn();
});
}
function tick(ms) {
clock.now += ms;
Array.from(intervals.values()).forEach((entry) => entry.fn());
runDueTimeouts();
}
return {
context,
elements,
ttsRequests,
transcribeCalls,
toasts,
sends,
clock,
recorder: () => recorder,
setLoud: (value) => { loud = value; },
setCapability: (value) => { capability = value; },
setTranscribeResponse: (value, status) => {
transcribeResponse = value;
transcribeStatus = status === undefined ? 200 : status;
},
setAssistantReply: (text) => { assistantRows = [{ dataset: { rawText: text } }]; },
setSession: (id) => { context.S.session = { session_id: id }; },
advance: (ms) => { clock.now += ms; },
tick,
runDueTimeouts,
flush,
};
}
// Walk one capture window: pre-roll audio, three loud frames so the VAD latches
// speech, then silence past the hangover so MediaRecorder.stop() fires.
async function captureSpeech(harness) {
const active = harness.recorder();
if (!active) throw new Error('voice mode never created a recorder');
active.ondataavailable({ data: { size: 512 } });
harness.setLoud(true);
for (let i = 0; i < 4; i += 1) harness.tick(100);
active.ondataavailable({ data: { size: 512 } });
harness.setLoud(false);
harness.advance(2500);
harness.tick(100);
await harness.flush();
}
async function startVoiceMode(harness) {
await harness.flush();
harness.elements.btnVoiceMode.click();
await harness.flush();
}
// One complete turn: speak, transcribe, let the app "answer", read it back.
async function runTurn(harness, { transcript, language, reply }) {
const payload = { ok: true, transcript };
if (language !== undefined) payload.language = language;
harness.setTranscribeResponse(payload);
await captureSpeech(harness);
harness.setAssistantReply(reply || 'An answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
}
async function restartListening(harness) {
harness.advance(1000);
harness.runDueTimeouts();
await harness.flush();
}
const scenarios = {};
scenarios.english_turn_speaks_english = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'What is the weather?', language: 'en' });
return { tts: harness.ttsRequests };
};
scenarios.russian_turn_speaks_russian = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
return { tts: harness.ttsRequests };
};
scenarios.spanish_turn_speaks_spanish = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: '¿Qué tal?', language: 'es', reply: 'Muy bien.' });
return { tts: harness.ttsRequests };
};
scenarios.missing_language_falls_back = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Hello there.' });
return { tts: harness.ttsRequests };
};
scenarios.unsupported_language_falls_back = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Bonjour tout le monde.', language: 'fr' });
return { tts: harness.ttsRequests };
};
scenarios.hostile_language_values_are_dropped = async () => {
const results = [];
const hostile = [
'ru; rm -rf /',
'../../ru_RU-irina-medium',
'ru\\u0000',
'RUSSIAN',
{ language: 'ru' },
['ru'],
42,
null,
'r',
'ru ru',
'x'.repeat(4096),
];
for (const language of hostile) {
const harness = makeHarness();
// eslint-disable-next-line no-await-in-loop
await startVoiceMode(harness);
// eslint-disable-next-line no-await-in-loop
await runTurn(harness, { transcript: 'Say something.', language });
results.push({ sent: String(language), tts: harness.ttsRequests });
}
return { results };
};
scenarios.voice_field_is_never_sent = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru' });
return { tts: harness.ttsRequests };
};
scenarios.language_does_not_leak_into_later_turn = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
await restartListening(harness);
await runTurn(harness, { transcript: 'And in English?', language: undefined });
await restartListening(harness);
await runTurn(harness, { transcript: '¿Y ahora?', language: 'es' });
return { tts: harness.ttsRequests };
};
scenarios.empty_transcript_does_not_arm_a_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: ' ', language: 'ru' });
await captureSpeech(harness);
const sendsAfterBlank = harness.sends.slice();
// A reply landing while the blank turn winds down must not inherit a
// language that transcript never earned.
harness.setAssistantReply('A stray answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
await restartListening(harness);
await runTurn(harness, { transcript: 'Hello.', language: undefined });
return { sendsAfterBlank, tts: harness.ttsRequests, sends: harness.sends };
};
scenarios.session_change_discards_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
await captureSpeech(harness);
harness.setSession('session-2');
harness.setAssistantReply('Reply that belongs to another chat.');
harness.context.autoReadLastAssistant();
await harness.flush();
const afterSwitch = harness.ttsRequests.slice();
await restartListening(harness);
await runTurn(harness, { transcript: 'Hello again.', language: undefined });
return { afterSwitch, tts: harness.ttsRequests };
};
scenarios.deactivation_discards_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
await captureSpeech(harness);
harness.elements.btnVoiceMode.click();
await harness.flush();
harness.setAssistantReply('Late reply after the user left voice mode.');
harness.context.autoReadLastAssistant();
await harness.flush();
const afterDeactivate = harness.ttsRequests.slice();
harness.elements.btnVoiceMode.click();
await harness.flush();
await runTurn(harness, { transcript: 'Fresh start.', language: undefined });
return { afterDeactivate, tts: harness.ttsRequests };
};
scenarios.transcribe_error_speaks_nothing = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ error: 'Whisper is down' }, 503);
await captureSpeech(harness);
harness.setAssistantReply('Some earlier answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { tts: harness.ttsRequests, toasts: harness.toasts };
};
(async () => {
const output = {};
const names = Object.keys(scenarios);
for (const name of names) {
// eslint-disable-next-line no-await-in-loop
output[name] = await scenarios[name]();
}
process.stdout.write(JSON.stringify(output, null, 1));
})().catch((error) => {
process.stderr.write(String((error && error.stack) || error));
process.exit(1);
});

View File

@ -343,7 +343,7 @@ def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
assert "/api/tts" in voice_script
assert "speakResponse(generation)" in voice_script
assert "window._splitForTTS(text,280)" in voice_script
assert "pending=fetchSpeech(chunks[index+1])" in voice_script
assert "pending=fetchSpeech(chunks[index+1],language)" in voice_script
assert "restartSoon(token,450)" in voice_script
assert "constraints.voiceIsolation=true" in voice_script
assert "highpass.frequency.value=140" in voice_script
@ -398,13 +398,27 @@ def test_voice_models_are_baked_and_runtime_has_no_public_egress():
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
assert tts_dockerfile.count("--chmod=0444") == 6
assert "ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18" in tts_dockerfile
assert "ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77" in tts_dockerfile
assert "ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa" in tts_dockerfile
assert "ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7" in tts_dockerfile
assert "ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0" in tts_dockerfile
assert "ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d" in tts_dockerfile
assert tts_dockerfile.count("--chmod=0444") == 12
assert "/opt/models/piper/en_US-amy-medium.onnx" in tts_dockerfile
assert "/opt/models/piper/ru_RU-irina-medium.onnx" in tts_dockerfile
assert "/opt/models/piper/es_MX-claude-high.onnx" in tts_dockerfile
assert "chmod 0555 /opt/models /opt/models/piper" in tts_dockerfile
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
assert "HERMES_TTS_VOICE=en_US-amy-medium" in tts_dockerfile
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
assert "download_voice" not in tts_server
assert "baked Piper voice is missing" in tts_server
assert "session_options.intra_op_num_threads = ONNX_THREADS" in tts_server
assert "session_options.intra_op_num_threads = threads" in tts_server
assert 'LANGUAGE_VOICE_MAP = {' in tts_server
assert '"en": "en_US-amy-medium"' in tts_server
assert '"ru": "ru_RU-irina-medium"' in tts_server
assert '"es": "es_MX-claude-high"' in tts_server
policies = _documents(HERMES / "networkpolicy.yaml")
voice_policy = next(

View File

@ -53,6 +53,15 @@ def test_goal_card_continues_after_local_judge_rejects_progress(
)
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
monkeypatch.setattr(
lanes,
"PROVIDER_HEALTH_PATHS",
{
"codex": tmp_path / "provider-health/codex.json",
"claude": tmp_path / "provider-health/claude.json",
},
)
monkeypatch.setattr(lanes, "fetch_quota_snapshot", lambda *_a, **_k: {})
lanes.atomic_json(
lanes.state_path("cassandra", "t_goal"),
{"goal_rejections": ["prior incomplete report"]},
@ -185,6 +194,15 @@ def test_capacity_fallback_preserves_first_claude_structured_response(
)
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
monkeypatch.setattr(
lanes,
"PROVIDER_HEALTH_PATHS",
{
"codex": tmp_path / "provider-health/codex.json",
"claude": tmp_path / "provider-health/claude.json",
},
)
monkeypatch.setattr(lanes, "fetch_quota_snapshot", lambda *_a, **_k: {})
claude = lanes.Route(
"claude", "claude-fable-5", "high", "claude-high", "test", "test", 1, ()
)
@ -194,8 +212,8 @@ def test_capacity_fallback_preserves_first_claude_structured_response(
monkeypatch.setattr(
lanes,
"select_route",
lambda _prompt, assignee, **_kwargs: codex
if assignee == "cli-codex-high"
lambda _prompt, assignee, **kwargs: codex
if kwargs.get("exclude_provider") == "claude"
else claude,
)
reports = [

View File

@ -157,13 +157,18 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
def select_route(_prompt, lane, **kwargs):
route_calls.append((lane, kwargs))
if lane == "cli-auto":
assert lane == "cli-auto"
if not kwargs.get("exclude_provider"):
return lanes.Route(
"codex", "gpt-5.6-terra", "xhigh", "codex-xhigh",
"switchyard-classifier", "vote", 1, (),
)
assert lane == "cli-claude-xhigh"
return _route("claude", "xhigh")
# Automatic reclassification at the retry boundary: this must remain
# a Jetson classifier decision, not a hardcoded manual alternate.
return lanes.Route(
"claude", "claude-opus-5", "xhigh", "claude-xhigh",
"switchyard-classifier", "vote", 1, (),
)
monkeypatch.setattr(lanes, "select_route", select_route)
fallbacks: list = []
@ -184,6 +189,17 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
assert reports == []
assert calls and calls[0][0] == "complete"
# Exactly two Switchyard selections: the initial classification and the
# automatic retry with an explicit failed-provider exclusion. No third
# (manual pin) call, because the classifier already preserved effort.
assert len(route_calls) == 2
initial_lane, initial_kwargs = route_calls[0]
assert initial_lane == "cli-auto"
assert not initial_kwargs.get("exclude_provider")
retry_lane, retry_kwargs = route_calls[1]
assert retry_lane == "cli-auto"
assert retry_kwargs["exclude_provider"] == "codex"
assert "quota" in retry_kwargs["exclude_reason"]
assert fallbacks == [("codex", "claude", "quota")]
assert any(
"Provider fallback: codex -> claude after a quota failure" in item
@ -196,6 +212,81 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
assert claude_health["state"] == "available"
def test_auto_failover_escalates_when_classifier_downgrades_effort(
tmp_path: Path, monkeypatch
):
task = SimpleNamespace(
id="t_auto_escalate",
status="running",
result=None,
current_run_id=39,
assignee="cli-auto",
max_runtime_seconds=120,
)
comments: list = []
calls: list = []
board = _lane_board(tmp_path, task, comments, calls)
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
route_calls: list = []
def select_route(_prompt, lane, **kwargs):
route_calls.append((lane, kwargs))
if lane == "cli-auto" and not kwargs.get("exclude_provider"):
return lanes.Route(
"codex", "gpt-5.6-terra", "high", "codex-high",
"switchyard-classifier", "vote", 1, (),
)
if lane == "cli-auto" and kwargs.get("exclude_provider") == "codex":
# Jetson reclassifies but picks a lower effort than the original
# route; the lane must never let capacity failover downgrade it.
return lanes.Route(
"claude", "claude-haiku-4-5", "low", "claude-low",
"switchyard-classifier", "vote", 1, (),
)
assert lane == "cli-claude-high"
return _route("claude", "high")
monkeypatch.setattr(lanes, "select_route", select_route)
fallbacks: list = []
monkeypatch.setattr(
lanes,
"record_provider_fallback",
lambda source, target, reason: fallbacks.append((source, target, reason)),
)
reports = [
lanes.ProcessResult(1, "You have hit your usage limit.", None, True),
lanes.ProcessResult(0, "done", dict(COMPLETED_RESULT), False),
]
monkeypatch.setattr(
lanes, "run_provider", lambda *_args, **_kwargs: reports.pop(0)
)
lanes.execute_claim("cassandra", "t_auto_escalate")
assert reports == []
assert calls and calls[0][0] == "complete"
assert [lane for lane, _ in route_calls] == [
"cli-auto",
"cli-auto",
"cli-claude-high",
]
# The provider fallback is recorded against the final, effort-preserved
# selection, not the transient low-effort classification.
assert fallbacks == [("codex", "claude", "quota")]
assert any(
"escalated to the original high" in item
for item in comments
)
assert any(
"Provider fallback: codex -> claude after a quota failure" in item
for item in comments
)
codex_health = json.loads(health_paths["codex"].read_text())
assert codex_health["state"] == "capacity-limited"
claude_health = json.loads(health_paths["claude"].read_text())
assert claude_health["state"] == "available"
def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown(
tmp_path: Path, monkeypatch
):
@ -212,9 +303,12 @@ def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown(
board = _lane_board(tmp_path, task, comments, calls)
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
def select_route(_prompt, lane, **_kwargs):
def select_route(_prompt, lane, **kwargs):
assert lane == "cli-auto"
return (
_route("codex", "high") if lane == "cli-auto" else _route("claude", "high")
_route("claude", "high")
if kwargs.get("exclude_provider") == "codex"
else _route("codex", "high")
)
monkeypatch.setattr(lanes, "select_route", select_route)
@ -286,9 +380,12 @@ def test_double_capacity_failure_blocks_transient_with_both_reasons(
board = _lane_board(tmp_path, task, comments, calls)
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
def select_route(_prompt, lane, **_kwargs):
def select_route(_prompt, lane, **kwargs):
assert lane == "cli-auto"
return (
_route("codex", "high") if lane == "cli-auto" else _route("claude", "high")
_route("claude", "high")
if kwargs.get("exclude_provider") == "codex"
else _route("codex", "high")
)
monkeypatch.setattr(lanes, "select_route", select_route)

View File

@ -182,6 +182,10 @@ class _Lane:
def _select_route(self, _prompt, assignee, **kwargs):
self.routes.append((assignee, kwargs))
if assignee == "cli-auto":
if kwargs.get("exclude_provider") == "claude":
return self._route("codex")
return self._route("claude")
if assignee.startswith("cli-codex"):
return self._route("codex")
return self._route("claude")

View File

@ -74,9 +74,9 @@ def test_router_outage_during_fallback_selection_blocks_transient(
_isolate_lane(tmp_path, monkeypatch, board)
selections: list = []
def select_route(_prompt, lane, **_kwargs):
def select_route(_prompt, lane, **kwargs):
selections.append(lane)
if lane == "cli-auto":
if lane == "cli-auto" and not kwargs.get("exclude_provider"):
return _route("codex", "medium")
raise RuntimeError("Switchyard worker routing failed: refused")
@ -89,7 +89,9 @@ def test_router_outage_during_fallback_selection_blocks_transient(
lanes.execute_claim("cassandra", "t_router_fb")
assert selections == ["cli-auto", "cli-claude-medium"]
# The automatic retry re-classifies via Switchyard (same "cli-auto" lane)
# with an explicit failed-provider exclusion, not a hardcoded manual lane.
assert selections == ["cli-auto", "cli-auto"]
kind, kwargs = calls[-1]
assert kind == "block" and kwargs["kind"] == "transient"
assert "Switchyard route selection is unavailable" in kwargs["reason"]

View File

@ -0,0 +1,203 @@
"""Browser-to-ffmpeg contracts for Hermes hands-free transcription uploads."""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
STT_PATCHER = ROOT / "dockerfiles/hermes-webui-stt-patch.py"
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent-9de9c25f"
MEDIA_FIXTURE = ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
SAFE_CONVERSION_ERROR = (
"Audio conversion failed: upload is invalid, incomplete, or uses an "
"unsupported codec"
)
def _media_chunks() -> tuple[dict[str, object], list[bytes]]:
fixture = json.loads(MEDIA_FIXTURE.read_text(encoding="utf-8"))
payload = MEDIA_FIXTURE.with_name(str(fixture["payload_file"])).read_bytes()
chunks = []
offset = 0
for size in fixture["chunk_sizes"]:
end = offset + int(size)
chunks.append(payload[offset:end])
offset = end
assert offset == len(payload)
return fixture, chunks
def _late_speech_upload(*, preserve_header: bool) -> bytes:
fixture, chunks = _media_chunks()
count = int(fixture["pre_speech_chunk_count"])
retained = chunks[count - 3 :]
if preserve_header:
retained.insert(0, chunks[0])
return b"".join(retained)
def _patched_transcription_module(tmp_path: Path):
target = tmp_path / "hermes-agent"
shutil.copytree(AGENT_FIXTURE, target)
env = os.environ.copy()
env["HERMES_AGENT_PATCH_ROOT"] = str(target)
subprocess.run(
[sys.executable, str(STT_PATCHER)],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
module_path = target / "tools/transcription_tools.py"
spec = importlib.util.spec_from_file_location(
"pinned_transcription_tools", module_path
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _convert(module, tmp_path: Path, payload: bytes, suffix: str):
source = tmp_path / f"voice-input{suffix}"
output_dir = tmp_path / f"converted-{suffix.removeprefix('.')}"
output_dir.mkdir()
source.write_bytes(payload)
return module._prepare_local_audio(str(source), str(output_dir))
def test_fixture_reproduces_the_current_ebml_header_failure(tmp_path: Path):
"""The red control is browser data after the old sliding pre-roll dropped chunk 0."""
broken = tmp_path / "current-late-speech.webm"
broken.write_bytes(_late_speech_upload(preserve_header=False))
result = subprocess.run(
["ffmpeg", "-v", "error", "-y", "-i", str(broken), str(tmp_path / "bad.wav")],
check=False,
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode != 0
assert "EBML header" in result.stderr
def test_header_preserved_browser_webm_reaches_real_conversion_boundary(
tmp_path: Path,
):
module = _patched_transcription_module(tmp_path)
prepared, error = _convert(
module,
tmp_path,
_late_speech_upload(preserve_header=True),
".webm",
)
assert error is None
assert prepared is not None
output = Path(prepared)
assert output.is_file()
assert output.read_bytes().startswith(b"RIFF")
def test_invalid_browser_upload_returns_bounded_error_without_ffmpeg_spam(
tmp_path: Path,
):
module = _patched_transcription_module(tmp_path)
prepared, error = _convert(
module,
tmp_path,
_late_speech_upload(preserve_header=False),
".webm",
)
assert prepared is None
assert error == SAFE_CONVERSION_ERROR
assert len(error) < 128
lowered = error.lower()
assert "ffmpeg version" not in lowered
assert "configuration:" not in lowered
assert "/tmp/" not in lowered
def test_local_conversion_accepts_browser_fallback_containers(tmp_path: Path):
module = _patched_transcription_module(tmp_path)
formats = ((".ogg", "libopus"), (".mp4", "aac"))
for suffix, codec in formats:
source = tmp_path / f"source{suffix}"
subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=0.4",
"-c:a",
codec,
str(source),
],
check=True,
capture_output=True,
text=True,
timeout=15,
)
prepared, error = _convert(module, tmp_path, source.read_bytes(), suffix)
assert error is None
assert prepared is not None
assert Path(prepared).read_bytes().startswith(b"RIFF")
def test_browser_uses_actual_recorder_mime_and_supported_extensions():
source = VOICE_JS.read_text(encoding="utf-8")
assert "let initialChunk=null" in source
assert "if(initialChunk){chunks.push(initialChunk);initialChunk=null;}" in source
assert "let recordedMime=recorder.mimeType||mime||''" in source
assert "if(event.data.type) recordedMime=event.data.type" in source
assert "audio/mp4;codecs=mp4a.40.2" in source
assert "if(normalized.indexOf('ogg')>=0) return 'ogg'" in source
assert "if(normalized.indexOf('mp4')>=0) return 'mp4'" in source
def test_stt_patch_is_built_fail_closed_into_the_webui_image(tmp_path: Path):
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text(
encoding="utf-8"
)
assert "COPY dockerfiles/hermes-webui-stt-patch.py" in dockerfile
assert "python /tmp/hermes-webui-stt-patch.py" in dockerfile
assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
drifted = tmp_path / "drifted-agent"
shutil.copytree(AGENT_FIXTURE, drifted)
transcription = drifted / "tools/transcription_tools.py"
source = transcription.read_text(encoding="utf-8")
transcription.write_text(
source.replace("Failed to convert audio for local STT", "upstream drift", 1),
encoding="utf-8",
)
env = os.environ.copy()
env["HERMES_AGENT_PATCH_ROOT"] = str(drifted)
result = subprocess.run(
[sys.executable, str(STT_PATCHER)],
cwd=ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "patch context changed" in result.stderr

View File

@ -0,0 +1,220 @@
"""Language allow-list contracts for the private Hermes chat TTS voice policy."""
from __future__ import annotations
import importlib.util
import io
import json
import sys
from types import SimpleNamespace
import pytest
from testing.tests.test_hermes_chat_support import ROOT
AMY = "en_US-amy-medium"
IRINA = "ru_RU-irina-medium"
CLAUDE = "es_MX-claude-high"
def _load_tts_server(monkeypatch):
server_path = ROOT / "dockerfiles" / "hermes-jetson-tts-server.py"
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)
class _FakeSessionOptions:
def __init__(self) -> None:
self.intra_op_num_threads = None
self.inter_op_num_threads = None
fake_onnxruntime = SimpleNamespace(
SessionOptions=_FakeSessionOptions,
InferenceSession=lambda *a, **k: SimpleNamespace(),
)
fake_piper = SimpleNamespace(
PiperConfig=SimpleNamespace(from_dict=lambda d: d),
PiperVoice=lambda **kwargs: SimpleNamespace(**kwargs),
SynthesisConfig=lambda **kwargs: SimpleNamespace(**kwargs),
)
monkeypatch.setitem(sys.modules, "onnxruntime", fake_onnxruntime)
monkeypatch.setitem(sys.modules, "piper", fake_piper)
spec.loader.exec_module(module)
return module
@pytest.fixture
def tts(monkeypatch):
return _load_tts_server(monkeypatch)
@pytest.mark.parametrize(
"language,expected",
[
("en", AMY),
("en-US", AMY),
("en_US", AMY),
("EN", AMY),
("En-Us", AMY),
("ru", IRINA),
("ru-RU", IRINA),
("ru_RU", IRINA),
("RU", IRINA),
("es", CLAUDE),
("es-MX", CLAUDE),
("es_MX", CLAUDE),
("es-ES", CLAUDE),
("es_ES", CLAUDE),
("ES", CLAUDE),
],
)
def test_allow_listed_languages_resolve_to_the_approved_voice(tts, language, expected):
assert tts.resolve_voice_name(language) == expected
@pytest.mark.parametrize(
"language",
[
None,
"",
" ",
"fr",
"fr-FR",
"de-DE",
"xx",
"en-GB",
"es-AR",
"english",
123,
1.5,
True,
[],
{},
{"lang": "ru"},
"../../etc/passwd",
"en_US-amy-medium/../../ru_RU-irina-medium",
"\x00ru",
"ru\x00",
],
)
def test_unknown_missing_or_malformed_language_falls_back_to_amy(tts, language):
assert tts.resolve_voice_name(language) == AMY
def test_default_voice_name_matches_the_dockerfile_env_default(tts):
assert tts.DEFAULT_VOICE_NAME == AMY
def test_resolved_voice_is_always_one_of_the_three_baked_names(tts):
assert frozenset({AMY, IRINA, CLAUDE}) == tts.BAKED_VOICE_NAMES
fuzz_inputs = [
"en", "ru", "es", "unknown", "", None, 42, "../../../etc/shadow",
"en_US-amy-medium\x00; rm -rf /", "RU-ru", "Es-Es", "en-us-extra",
]
for value in fuzz_inputs:
assert tts.resolve_voice_name(value) in tts.BAKED_VOICE_NAMES
def test_client_voice_field_cannot_override_the_language_policy(tts):
"""The POST handler must select the voice from "language" only.
A malicious or stale "voice" field in a hostile/legacy request must never
change which baked model answers the request.
"""
calls: list[str] = []
class _RecordingVoice:
def __init__(self, name: str) -> None:
self.name = name
def synthesize_wav(self, text, wav_file, syn_config) -> None:
calls.append(self.name)
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(b"\x00\x00")
class _RecordingHandler(tts.SpeechHandler):
def __init__(self, payload):
request = json.dumps(payload).encode("utf-8")
self.path = "/v1/audio/speech"
self.headers = {"Content-Length": str(len(request))}
self.rfile = io.BytesIO(request)
self.wfile = io.BytesIO()
self.status = None
self.response_headers = {}
self.server = SimpleNamespace(
voices={
AMY: _RecordingVoice(AMY),
IRINA: _RecordingVoice(IRINA),
CLAUDE: _RecordingVoice(CLAUDE),
},
default_voice_name=AMY,
)
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
# A payload that supplies an attacker/legacy "voice" value but no
# language must resolve to the safe default, never the "voice" value.
handler = _RecordingHandler({"input": "hi", "voice": IRINA})
handler.do_POST()
assert handler.status == 200
assert handler.response_headers["X-TTS-Voice"] == AMY
# A payload supplying both must still be governed by "language" alone.
handler = _RecordingHandler({"input": "hi", "voice": CLAUDE, "language": "ru"})
handler.do_POST()
assert handler.status == 200
assert handler.response_headers["X-TTS-Voice"] == IRINA
assert calls == [AMY, IRINA]
def test_no_client_string_reaches_a_filesystem_path(tts):
"""resolve_voice_name must only ever return a fixed, baked literal.
This is the property that keeps a client from ever causing the server to
build a Path out of attacker-controlled text: the return value is always
a member of the fixed allow-list, regardless of input shape.
"""
hostile_inputs = [
"../../../../etc/passwd",
"/etc/passwd",
"en_US-amy-medium/../../../etc/passwd",
"ru_RU-irina-medium\x00.onnx",
"es_MX-claude-high; cat /etc/shadow",
"\n\ren",
"en" + "/" * 200,
" ",
]
for value in hostile_inputs:
result = tts.resolve_voice_name(value)
assert result in tts.BAKED_VOICE_NAMES
assert "/" not in result
assert ".." not in result
assert "\x00" not in result
def test_normalize_language_rejects_non_string_input(tts):
assert tts.normalize_language(None) is None
assert tts.normalize_language(123) is None
assert tts.normalize_language([]) is None
assert tts.normalize_language("") is None
assert tts.normalize_language(" ") is None
assert tts.normalize_language("En_US") == "en-us"
def test_default_voice_name_is_one_of_the_baked_voices(tts):
assert tts.DEFAULT_VOICE_NAME in tts.BAKED_VOICE_NAMES
def test_load_voices_fails_closed_when_a_baked_model_is_missing(tts, tmp_path):
with pytest.raises(RuntimeError, match="baked Piper voice is missing"):
tts.load_voices(tmp_path, threads=1)

View File

@ -12,17 +12,24 @@ import sys
ROOT = Path(__file__).resolve().parents[2]
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent"
PATCHER = ROOT / "dockerfiles/hermes-webui-atlas-patch.py"
VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
VOICE_CSS = ROOT / "dockerfiles/hermes-webui-atlas-voice.css"
DOM_PROBE = ROOT / "testing/probes/hermes_voice_instrument_probe.js"
MEDIARECORDER_FIXTURE = (
ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
)
def _patched_fixture(tmp_path: Path) -> Path:
target = tmp_path / "hermes-webui"
agent_target = tmp_path / "hermes-agent"
shutil.copytree(FIXTURE, target)
shutil.copytree(AGENT_FIXTURE, agent_target)
env = os.environ.copy()
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
env["HERMES_AGENT_PATCH_ROOT"] = str(agent_target)
subprocess.run(
[sys.executable, str(PATCHER)],
cwd=ROOT,
@ -57,6 +64,52 @@ def test_real_upstream_fixture_receives_visual_instrument_contract(tmp_path: Pat
assert '<button' not in index[index.index('id="voiceModeBar"') : index.index('<textarea')]
def test_patched_webui_has_no_user_voice_choice_or_client_voice_field(
tmp_path: Path,
):
"""The pinned settings DOM and every TTS path leave speakers to policy."""
target = _patched_fixture(tmp_path)
index = (target / "static/index.html").read_text(encoding="utf-8")
ui = (target / "static/ui.js").read_text(encoding="utf-8")
panels = (target / "static/panels.js").read_text(encoding="utf-8")
boot = (target / "static/boot.js").read_text(encoding="utf-8")
i18n = (target / "static/i18n.js").read_text(encoding="utf-8")
config = (target / "api/config.py").read_text(encoding="utf-8")
routes = (target / "api/routes.py").read_text(encoding="utf-8")
assert "settingsTtsVoice" not in index
assert "settings_label_tts_voice" not in index
assert "settings_desc_tts_voice" not in index
assert "Default system voice" not in index
assert 'id="settingsTtsEngine"' in index
assert 'id="btnVoiceMode"' in index
assert 'id="voiceModeBar"' in index
assert "hermes-tts-voice" not in ui
assert "voice:voice" not in ui
assert (
"body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, "
"engine:engineOverride||'edge'})"
) in ui
assert "settingsTtsVoice" not in panels
assert "tts_voice" not in panels
assert "localStorage.removeItem('hermes-tts-voice')" in panels
assert panels.count("hermes-tts-voice") == 1
assert "hermes-tts-voice" not in boot
assert "tts_voice" not in boot
assert "text: clean, voice" not in boot
assert '"tts_voice"' not in config
assert "settings_label_tts_voice" not in i18n
assert "settings_desc_tts_voice" not in i18n
atlas_route = routes.split('if engine == "atlas":', 1)[1].split(
"# ── ElevenLabs TTS", 1
)[0]
assert '"input": text' in atlas_route
assert '"voice"' not in atlas_route
assert 'request_payload["language"] = _atlas_language' in atlas_route
def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion():
css = VOICE_CSS.read_text(encoding="utf-8")
@ -86,7 +139,7 @@ def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion():
def test_dom_probe_exercises_actual_injected_voice_script():
result = subprocess.run(
["node", str(DOM_PROBE), str(VOICE_JS)],
["node", str(DOM_PROBE), str(VOICE_JS), str(MEDIARECORDER_FIXTURE)],
cwd=ROOT,
check=True,
capture_output=True,
@ -101,8 +154,9 @@ def test_visual_slice_preserves_private_voice_request_and_capture_contract():
assert script.count("navigator.mediaDevices.getUserMedia(") == 1
assert "form.append('file',new File([blob],'voice-input.'+ext" in script
assert "fetch('/api/transcribe',{method:'POST',body:form})" in script
assert "body:JSON.stringify({text:chunk,engine:'atlas'})" in script
assert "const request={text:chunk,engine:'atlas'}" in script
assert "if(language) request.language=language" in script
assert "speakResponse(generation)" in script
assert "window._voiceModeImmediateSend" in script
assert "mute" not in script.lower()
assert "language" not in script.lower()
assert "const TTS_LANGUAGES=['en','ru','es']" in script

View File

@ -0,0 +1,677 @@
"""Voice-mode language routing: private Whisper STT decides the Piper voice.
Every assertion here runs without a GPU, a microphone or a cluster. The browser
contract is exercised by driving the real ``atlas-voice.js`` inside a stub DOM
(``testing/tests/data/atlas_voice_language_probe.js``), and the two server-side
trust boundaries are exercised by applying the real image patch to fixtures that
carry the exact upstream anchors and then importing the patched result.
"""
from __future__ import annotations
import importlib.util
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from testing.tests.test_hermes_chat_support import HERMES, ROOT
DOCKERFILES = ROOT / "dockerfiles"
ATLAS_PATCH = DOCKERFILES / "hermes-webui-atlas-patch.py"
VOICE_SCRIPT = DOCKERFILES / "hermes-webui-atlas-voice.js"
VOICE_PROBE = ROOT / "testing" / "tests" / "data" / "atlas_voice_language_probe.js"
WEBUI_FIXTURE = ROOT / "testing" / "fixtures" / "hermes-webui-0.52.181"
ATLAS_TTS_URL = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
# Voices baked by the multilingual Piper work (PR #26): en=amy, ru=irina,
# es=claude. Anything outside this set must resolve to English.
SUPPORTED = ("en", "ru", "es")
# ---------------------------------------------------------------------------
# Module loaders
# ---------------------------------------------------------------------------
def _load_stt_server(monkeypatch):
"""Import the Jetson Whisper service without CUDA, torch or whisper."""
path = DOCKERFILES / "hermes-jetson-stt-server.py"
spec = importlib.util.spec_from_file_location("hermes_jetson_stt_server", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"torch",
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
)
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
spec.loader.exec_module(module)
return module
def _load_stt_client():
"""Import the local-command STT client that Hermes shells out to."""
path = HERMES / "scripts" / "hermes_stt_client.py"
spec = importlib.util.spec_from_file_location("hermes_stt_client", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# ---------------------------------------------------------------------------
# Patch fixtures — each file carries the exact upstream fragment the image
# patch pins, so importing the patched result exercises the inserted code.
# ---------------------------------------------------------------------------
INDEX_FIXTURE = (
'<select id="settingsTtsEngine">'
'<option value="browser">Browser speech synthesis</option>'
'<option value="edge">Edge TTS (server)</option></select>\n'
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n'
)
UI_FIXTURE = """function _playEdgeTtsChunked(text, btn){
fetch('/api/tts',{method:'POST',body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
}
function readAloud(clean, btn, engine){
if(engine==='edge'){
_playEdgeTtsChunked(clean, btn);
}
}
function autoRead(clean, engine){
if(engine==='edge'){
_playEdgeTtsChunked(clean, null);
}
}
"""
HELPERS_FIXTURE = '''"""Stand-in for the WebUI helper module the patched code imports."""
def bad(handler, message, status=400):
return {"status": status, "error": message}
'''
ROUTES_FIXTURE = '''"""Stand-in carrying the exact upstream anchors the Atlas TTS patch pins."""
import json
import os
from urllib.request import ProxyHandler, Request, build_opener
class _NoRedirectTtsHandler:
"""Placeholder for the upstream no-redirect opener handler."""
class _Logger:
def __init__(self):
self.failures = []
def exception(self, message):
self.failures.append(message)
logger = _Logger()
UPSTREAM_REQUESTS = []
class _Upstream:
def __init__(self, payload):
self._payload = payload
def read(self):
return self._payload
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def _buffer_tts_audio_response(response):
return response.read()
def _tts_open(req, *, timeout=30, opener_factory=None):
"""Thin network seam for the TTS upstream fetch so tests can intercept it."""
UPSTREAM_REQUESTS.append(json.loads(req.data.decode("utf-8")))
return _Upstream(b"RIFFsynthetic")
def _handle_tts(handler, data, text, rate_str, engine):
# ── ElevenLabs TTS ──────────────────────────────────────────────────
return None
'''
UPLOAD_FIXTURE = '''"""Stand-in carrying the exact upstream /api/transcribe response anchor."""
def j(handler, payload, status=200):
return {"status": status, "payload": payload}
def handle_transcribe(handler, result):
try:
transcript = str(result.get('transcript') or '').strip()
return j(handler, {'ok': True, 'transcript': transcript})
except ValueError as error:
return j(handler, {'error': str(error)}, status=400)
'''
TRANSCRIPTION_FIXTURE = '''"""Stand-in carrying the exact upstream local-command STT envelope anchor."""
import contextlib
from pathlib import Path
class _Logger:
def info(self, *args):
return None
logger = _Logger()
def _transcribe_local_command(file_path, normalized_model, output_dir):
try:
with contextlib.nullcontext(output_dir):
txt_files = sorted(Path(output_dir).glob("*.txt"))
transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
logger.info(
"Transcribed %s via local STT command (%s, %d chars)",
Path(file_path).name,
normalized_model,
len(transcript_text),
)
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
except OSError as error:
return {"success": False, "transcript": "", "error": str(error)}
'''
def _write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
@pytest.fixture
def patched_webui(tmp_path, monkeypatch):
"""Apply the real Atlas image patch to fixture trees and import the result."""
webui = tmp_path / "hermes-webui"
agent = tmp_path / "hermes"
# Start with the pinned full-surface fixture introduced by PR #39 so this
# test proves the language pipeline composes with its voice-selector removal
# and conversation instrument, not merely with the older #27 anchors.
shutil.copytree(WEBUI_FIXTURE, webui)
_write(webui / "api" / "__init__.py", "")
_write(webui / "api" / "helpers.py", HELPERS_FIXTURE)
_write(webui / "api" / "routes.py", ROUTES_FIXTURE)
_write(webui / "api" / "upload.py", UPLOAD_FIXTURE)
_write(agent / "tools" / "__init__.py", "")
_write(agent / "tools" / "transcription_tools.py", TRANSCRIPTION_FIXTURE)
environment = dict(os.environ)
environment["HERMES_WEBUI_PATCH_ROOT"] = str(webui)
environment["HERMES_AGENT_PATCH_ROOT"] = str(agent)
completed = subprocess.run(
[sys.executable, str(ATLAS_PATCH)],
env=environment,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr or completed.stdout
for name in ("api", "api.helpers", "api.routes", "api.upload", "tools",
"tools.transcription_tools"):
sys.modules.pop(name, None)
monkeypatch.syspath_prepend(str(agent))
monkeypatch.syspath_prepend(str(webui))
import api.routes as routes # noqa: PLC0415
import api.upload as upload # noqa: PLC0415
import tools.transcription_tools as transcription # noqa: PLC0415
yield SimpleNamespace(
webui=webui,
agent=agent,
routes=routes,
upload=upload,
transcription=transcription,
)
for name in ("api", "api.helpers", "api.routes", "api.upload", "tools",
"tools.transcription_tools"):
sys.modules.pop(name, None)
class _Handler:
"""Just enough BaseHTTPRequestHandler surface for the Atlas TTS branch."""
def __init__(self):
self.status = None
self.headers_sent = {}
self.wfile = SimpleNamespace(write=self._write)
self.body = b""
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.headers_sent[name] = value
def end_headers(self):
return None
def _write(self, payload):
self.body += payload
def _atlas_tts(patched, monkeypatch, data):
"""Run the patched Atlas branch and return the JSON it sent to hermes-tts."""
monkeypatch.setenv("HERMES_WEBUI_ATLAS_TTS_URL", ATLAS_TTS_URL)
patched.routes.UPSTREAM_REQUESTS.clear()
handler = _Handler()
result = patched.routes._handle_tts(handler, data, "Some reply.", "", "atlas")
assert result is True, "the Atlas branch must own the response"
assert handler.status == 200
assert len(patched.routes.UPSTREAM_REQUESTS) == 1
return patched.routes.UPSTREAM_REQUESTS[0]
# ---------------------------------------------------------------------------
# 1. Whisper service reports the language it actually decoded with
# ---------------------------------------------------------------------------
def test_stt_response_carries_whisper_detected_language(monkeypatch):
module = _load_stt_server(monkeypatch)
payload = module._transcription_payload(
{
"language": "ru",
"segments": [
{"text": " Как дела?", "no_speech_prob": 0.1, "avg_logprob": -0.2}
],
}
)
assert payload["text"] == "Как дела?"
assert payload["language"] == "ru"
assert payload["model"] == module.MODEL_NAME
@pytest.mark.parametrize(
("raw", "expected"),
[
("en", "en"),
("RU", "ru"),
(" es ", "es"),
("yue", "yue"),
("fr", "fr"),
("en-US", ""),
("en_US", ""),
("e", ""),
("english", ""),
("", ""),
("../en", ""),
("en\x00", ""),
("ru; rm -rf /", ""),
("рус", ""),
(None, ""),
(7, ""),
(["ru"], ""),
({"language": "ru"}, ""),
],
)
def test_stt_language_field_is_shape_validated(monkeypatch, raw, expected):
module = _load_stt_server(monkeypatch)
assert module._detected_language({"language": raw}) == expected
def test_stt_language_absent_when_whisper_omits_it(monkeypatch):
module = _load_stt_server(monkeypatch)
assert module._detected_language({}) == ""
assert module._detected_language("not a result") == ""
assert module._transcription_payload({"text": "hi"})["language"] == ""
# ---------------------------------------------------------------------------
# 2. The local-command client carries the language without breaking the
# .txt contract Hermes reads the transcript from
# ---------------------------------------------------------------------------
def test_stt_client_writes_language_sidecar_beside_the_txt_contract(tmp_path):
module = _load_stt_client()
module._write_result(tmp_path, "voice-input", "Как дела?", "ru")
assert (tmp_path / "voice-input.txt").read_text(encoding="utf-8") == "Как дела?"
assert (tmp_path / "voice-input.language").read_text(encoding="utf-8") == "ru"
# Hermes globs *.txt and reads the first match: the sidecar must not join it.
assert sorted(p.name for p in tmp_path.glob("*.txt")) == ["voice-input.txt"]
def test_stt_client_omits_the_sidecar_when_no_language_was_detected(tmp_path):
module = _load_stt_client()
module._write_result(tmp_path, "voice-input", "Hello.", "")
assert (tmp_path / "voice-input.txt").exists()
assert not (tmp_path / "voice-input.language").exists()
@pytest.mark.parametrize(
("raw", "expected"),
[
("en", "en"),
("ES", "es"),
(" ru ", "ru"),
("en-US", ""),
("", ""),
("../../etc/passwd", ""),
("en\n", "en"),
("e", ""),
(None, ""),
(12, ""),
(["en"], ""),
],
)
def test_stt_client_normalises_the_service_language_field(raw, expected):
module = _load_stt_client()
assert module._normalize_language(raw) == expected
# ---------------------------------------------------------------------------
# 3. The patched agent envelope and /api/transcribe response carry it through
# ---------------------------------------------------------------------------
def test_patched_local_command_envelope_carries_the_sidecar_language(patched_webui, tmp_path):
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Как дела?", encoding="utf-8")
(output / "voice-input.language").write_text("ru\n", encoding="utf-8")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result == {
"success": True,
"transcript": "Как дела?",
"provider": "local_command",
"language": "ru",
}
def test_patched_local_command_envelope_defaults_to_no_language(patched_webui, tmp_path):
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result["transcript"] == "Hello."
assert result["language"] == ""
@pytest.mark.parametrize(
"hostile",
["en-US", "../../en", "en; rm -rf /", "e", "english", "", "\x00en", "e n"],
)
def test_patched_local_command_envelope_rejects_malformed_sidecars(
patched_webui, tmp_path, hostile
):
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
(output / "voice-input.language").write_text(hostile, encoding="utf-8")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result["language"] == ""
def test_patched_local_command_envelope_survives_an_undecodable_sidecar(
patched_webui, tmp_path
):
"""A corrupt sidecar must cost the language, never the transcript."""
output = tmp_path / "stt-out"
output.mkdir()
(output / "voice-input.txt").write_text("Hello.", encoding="utf-8")
(output / "voice-input.language").write_bytes(b"\xff\xfe\x00ru")
result = patched_webui.transcription._transcribe_local_command(
"/tmp/voice-input.wav", "small", output
)
assert result["success"] is True
assert result["transcript"] == "Hello."
assert result["language"] == ""
def test_patched_transcribe_response_reports_the_language(patched_webui):
response = patched_webui.upload.handle_transcribe(
None, {"success": True, "transcript": " Как дела? ", "language": "ru"}
)
assert response["payload"] == {"ok": True, "transcript": "Как дела?", "language": "ru"}
@pytest.mark.parametrize(
"hostile",
["", None, "en-US", "englishhh", "../en", 5, ["ru"], {"a": "b"}, "e"],
)
def test_patched_transcribe_response_blanks_untrusted_languages(patched_webui, hostile):
response = patched_webui.upload.handle_transcribe(
None, {"success": True, "transcript": "Hello.", "language": hostile}
)
assert response["payload"]["language"] == ""
assert response["payload"]["transcript"] == "Hello."
# ---------------------------------------------------------------------------
# 4. The /api/tts trust boundary: allow-list only, and never `voice`
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("language", SUPPORTED)
def test_atlas_tts_forwards_allow_listed_languages(patched_webui, monkeypatch, language):
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas", "language": language})
assert body["language"] == language
assert body["model"] == "piper"
@pytest.mark.parametrize(
"hostile",
[
"fr",
"de",
"",
None,
"EN-GB",
" RU ",
"ru-RU",
"es_MX",
"../../ru_RU-irina-medium",
"ru; rm -rf /",
"ru\x00",
"ру",
5,
["ru"],
{"language": "ru"},
True,
"x" * 8192,
],
)
def test_atlas_tts_omits_untrusted_languages(
patched_webui, monkeypatch, hostile
):
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas", "language": hostile})
assert "language" not in body
def test_atlas_tts_language_is_absent_when_the_client_sends_none(patched_webui, monkeypatch):
body = _atlas_tts(patched_webui, monkeypatch, {"engine": "atlas"})
assert "language" not in body
def test_atlas_tts_voice_field_cannot_steer_synthesis(patched_webui, monkeypatch):
body = _atlas_tts(
patched_webui,
monkeypatch,
{"engine": "atlas", "voice": "ru_RU-irina-medium", "language": "en"},
)
assert body["language"] == "en"
body = _atlas_tts(
patched_webui,
monkeypatch,
{"engine": "atlas", "voice": "es_MX-claude-high"},
)
assert "language" not in body
def test_atlas_tts_language_helper_only_ever_returns_a_baked_voice_language(patched_webui):
resolve = patched_webui.routes._atlas_tts_language
hostile = [
None, 0, 1, -1, True, False, [], {}, set(), object(), b"ru",
"", " ", "\t\n", "en", "EN", "en-US", "en_us", "ru-RU", "es-MX",
"e", "eng", "english", "ru ru", "ru;es", "../ru", "ru\x00", "ру",
"x" * 65536, "en" * 4096,
]
for value in hostile:
expected = value if value in SUPPORTED else ""
assert resolve({"language": value}) == expected
for value in hostile:
assert resolve(value) == ""
assert resolve({"voice": "ru_RU-irina-medium"}) == ""
def test_manual_tts_button_body_still_carries_no_language(patched_webui):
"""The read-aloud button has no trusted STT signal, so it must stay Amy."""
ui = (patched_webui.webui / "static" / "ui.js").read_text(encoding="utf-8")
assert "engine:engineOverride||'edge'" in ui
assert "language" not in ui
# ---------------------------------------------------------------------------
# 5. Browser contract, driven through the real atlas-voice.js
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def voice_probe():
node = shutil.which("node")
if not node:
pytest.skip("node is required to drive the browser voice-mode contract")
completed = subprocess.run(
[node, str(VOICE_PROBE), str(VOICE_SCRIPT)],
capture_output=True,
text=True,
timeout=180,
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
@pytest.mark.parametrize(
("scenario", "expected"),
[
("english_turn_speaks_english", "en"),
("russian_turn_speaks_russian", "ru"),
("spanish_turn_speaks_spanish", "es"),
],
)
def test_voice_mode_speaks_the_language_whisper_detected(voice_probe, scenario, expected):
requests = voice_probe[scenario]["tts"]
assert requests, "voice mode never reached /api/tts"
for request in requests:
assert request["engine"] == "atlas"
assert request["language"] == expected
@pytest.mark.parametrize(
"scenario", ["missing_language_falls_back", "unsupported_language_falls_back"]
)
def test_voice_mode_omits_language_without_a_trusted_signal(voice_probe, scenario):
requests = voice_probe[scenario]["tts"]
assert requests, "voice mode never reached /api/tts"
for request in requests:
assert "language" not in request
def test_voice_mode_drops_hostile_language_values(voice_probe):
for case in voice_probe["hostile_language_values_are_dropped"]["results"]:
for request in case["tts"]:
assert "language" not in request, case["sent"]
def test_voice_mode_never_sends_a_voice_field(voice_probe):
for request in voice_probe["voice_field_is_never_sent"]["tts"]:
assert set(request) <= {"text", "engine", "language"}
assert "voice" not in request
def test_voice_mode_does_not_reuse_a_previous_turn_language(voice_probe):
requests = voice_probe["language_does_not_leak_into_later_turn"]["tts"]
assert len(requests) == 3
assert requests[0]["language"] == "ru"
assert "language" not in requests[1]
assert requests[2]["language"] == "es"
def test_voice_mode_ignores_language_from_an_empty_transcript(voice_probe):
result = voice_probe["empty_transcript_does_not_arm_a_language"]
assert result["sendsAfterBlank"] == []
assert result["sends"] == ["Hello."]
assert result["tts"], "the follow-up turn should still be spoken"
for request in result["tts"]:
assert "language" not in request
def test_voice_mode_discards_language_when_the_session_changes(voice_probe):
result = voice_probe["session_change_discards_language"]
assert result["afterSwitch"] == []
for request in result["tts"]:
assert "language" not in request
def test_voice_mode_discards_language_when_voice_mode_is_turned_off(voice_probe):
result = voice_probe["deactivation_discards_language"]
assert result["afterDeactivate"] == []
for request in result["tts"]:
assert "language" not in request
def test_voice_mode_speaks_nothing_when_transcription_fails(voice_probe):
result = voice_probe["transcribe_error_speaks_nothing"]
assert result["tts"] == []
assert any("Whisper is down" in toast for toast in result["toasts"])
# ---------------------------------------------------------------------------
# 6. Build-time enforcement and documented semantics
# ---------------------------------------------------------------------------
def test_image_build_verifies_every_language_routing_patch():
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
assert "'language': detected" in dockerfile
assert "def _atlas_tts_language(body):" in dockerfile
assert 'request_payload["language"] = _atlas_language' in dockerfile
assert '"language": detected_language' in dockerfile
assert "takeSttLanguage(token)" in dockerfile
assert "/opt/hermes-webui/api/upload.py" in dockerfile
assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
def test_atlas_patch_roots_are_overridable_for_offline_verification():
patch = ATLAS_PATCH.read_text(encoding="utf-8")
assert 'os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")' in patch
assert 'os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes")' in patch
def test_notes_document_the_stt_driven_voice_selection_and_its_limits():
notes = (HERMES / "NOTES.md").read_text(encoding="utf-8")
assert "STT-detected language" in notes
for marker in ("hands-free", "Typed messages", "en_US-amy-medium"):
assert marker in notes