Compare commits

...

2 Commits

Author SHA1 Message Date
jenkins
d3cb6e9045 hermes(hux): add contract-only foundation for the chat UX program
Schemas, examples and a flag registry for the twelve HUX cards, a dependency-free
validator, the governance rules (memory ledger, autonomy matrix, friendly modes
mapped to real Switchyard routes, privacy defaults, suggestion gating, release
state machine) and the contract doc UI work codes against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-23 22:30:41 -03:00
jenkins
d254931a14 feat(hermes): ship full-duplex voice release 2026-08-23 22:13:52 -03:00
70 changed files with 5519 additions and 142 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,135 @@
# Hermes chat UX program (HUX): shared contracts
This is the contract-only foundation for the twelve-card program that makes
`chat.bstein.dev` a first-class assistant surface. Nothing here changes runtime
behaviour. It fixes the shapes, state machines and ownership that every later
slice codes against, so the UI side and the backend side can work in parallel
without inventing informal shapes.
Schemas live in `services/hermes/contracts/hux/` as JSON Schema 2020-12 with
one worked example per record under `examples/`. The rules a schema cannot
express (state machines, capability matrix, mode catalog, privacy defaults,
suggestion gating, flag dependencies) live in
`services/hermes/scripts/hux_policy.py`; `hux_contracts.py` validates records
without external packages. `testing/tests/test_hermes_hux_contracts.py` keeps
schemas, examples, rules and the live Switchyard catalog in agreement.
## Where the backend lives
The chat product has exactly one repo-owned HTTP surface on the request path:
the Go tenant router (`services/hermes/router/`). It already owns the Keycloak
identity to slot mapping, the admin-surface deny list, durable state on its
PVC, and the JS/CSS bridge it injects into every WebUI page. New HUX APIs are
therefore router routes under `/hux/v1/`, storing per-tenant records on the
tenant PVC beside the existing Telegram media store. The upstream WebUI is a
pinned image patched at build time; UI work goes through the injected bridge
and, where unavoidable, the fail-closed patch scripts in `dockerfiles/`.
`/hux/v1` returns 404 until `hux.foundation` is enabled. Every other flag
depends on it (see `flags.json`), and the router refuses to serve a flag whose
dependencies are off.
## Identity and provenance rules
- Users are `usr_<hash>`: the hashed Keycloak subject the router already uses.
Raw subjects, emails and Telegram ids never appear in a HUX record.
- Every record carries `provenance` (surface, actor, time, session/run, route,
build). `route.requested` is a friendly mode or a route id; a vendor model
name never appears in a contract. `build` pins the commit and image digest
so the timeline can say which release produced a decision.
- Evidence is referenced, never inlined (`evidence_ref`). The UI expands a
reference through the API that owns it, which is how redaction stays in one
place.
- `sensitivity` and `redaction` are mandatory on events and memory. Records
leaving a tenant are redacted according to `redaction.level` first.
## API surface (contract only)
All routes are tenant-scoped through the router; the caller never names the
tenant. Responses are the records below, wrapped as `{"items": [...],
"next": cursor}` for lists.
| Area | Routes | Record |
|---|---|---|
| Events (HUX-01) | `GET /hux/v1/conversations/{id}/events?after_seq=N` (JSON), `GET .../events/stream` (SSE, `id:` = seq) | `hux.event.v1` |
| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `POST /hux/v1/memory/{id}/{approve,reject,forget}`, `GET /hux/v1/memory/export` | `hux.memory.v1` |
| Projects (HUX-03) | `GET/POST/PATCH /hux/v1/projects`, `GET/PATCH /hux/v1/conversations`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/search?q=` | `hux.project.v1`, `hux.conversation.v1` |
| Artifacts (HUX-04) | `GET/POST /hux/v1/artifacts`, `POST /hux/v1/artifacts/{id}/versions`, `GET .../versions/{n}/diff?from=`, `POST .../promote` | `hux.artifact.v1` |
| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET /hux/v1/approvals`, `POST /hux/v1/approvals/{id}` (`once|session|always|deny`, the gateway's own choices), `POST /hux/v1/runs/{id}/stop` returns the receipt | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1` |
| Modes (HUX-06) | `GET /hux/v1/modes`, `PUT /hux/v1/conversations/{id}/mode` | `hux.mode.v1` |
| Research (HUX-08) | `GET /hux/v1/messages/{id}/citations`, `GET /hux/v1/sources/{id}`, `GET/PATCH /hux/v1/notebooks/{id}` | `hux.source.v1`, `hux.passage.v1`, `hux.citation.v1`, `hux.research_notebook.v1` |
| Onboarding (HUX-09) | `GET /hux/v1/suggestions?context=`, `POST /hux/v1/suggestions/{id}/{dismiss,never,acted}` | `hux.suggestion.v1`, `hux.suggestion_state.v1` |
| Privacy (HUX-10) | `GET /hux/v1/privacy/policy`, `POST /hux/v1/conversations/{id}/forget` | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` |
| Release (HUX-12) | `GET /hux/v1/releases` (operator surface, worker only) | `hux.release.v1` |
Multimodal (HUX-07) reuses artifacts for images/audio (`type` image/audio,
lineage for variants) and events for transcript corrections
(`kind: artifact.version` on the transcript artifact). It has no schema of
its own by design.
## State machines and rules that are fixed here
- **Memory** is a ledger: `proposed -> active|rejected`, `active ->
expired|forgotten`, `expired -> forgotten`. Nothing edits content in place;
a correction is a new entry whose `source` points at the old one. Sensitive
entries need `approval_mode: ask` and must expire or decay; restricted
content and the credentials/minors/location/biometric topics are never
written. Forgotten entries keep their audit trail and drop their content.
- **Autonomy** has three levels. Read-only capabilities are always allowed,
mutating ones ask unless the level is `autonomous`, `network` is denied in
`safe`, and `deploy` always asks whatever the level or grant says. Explicit
grants override the matrix; an unexpired `deny` beats everything. See
`default_capability_matrix()` and `effective_decision()`.
- **Approvals** move once from `pending` and are then terminal. Choices are
the gateway's existing `once|session|always|deny`.
- **Cancellation** produces a receipt with an outcome and the side effects
observed, each marked reverted or not. "Stop" is not done until the receipt
exists.
- **Modes** are intents mapped to Switchyard constraints, never to a vendor.
Fast, Thoughtful, Research and Create all allow both hosted providers;
Private is local-only, memory off, web off, ephemeral, and cannot be
overridden to a hosted route. The advanced drawer may pin an exact
`atlas/manual/...` route id; the test suite checks each default route
exists in `switchyard-configmap.yaml`.
- **Privacy** scopes sensitive topics to the conversation, never shares them
across surfaces, decays them within 30 days, and shows a just-in-time notice
with real controls (`forget_this_conversation`, `switch_to_private`,
`disable_memory_here`, `dismiss`). A daily retention audit record proves the
rules ran.
- **Suggestions** need a trigger context, are always dismissable, honour
`max_shows` (at most 5) and a cooldown of at least an hour, and stop for
good on `never_again`, dismissal or action.
- **Release** is linear: `reviewed -> merged -> built -> verified ->
deployed -> converged -> live_verified`, with `rolled_back` reachable from
any state that has an image. Each step names its evidence: merge commit,
CI build plus image digest, Harbor digest equal to the image digest, Flux
revision, running pod digest equal to the image digest, passing health
probe. `release_transition_problems()` is the single source of truth the
Worker's task state must call before it claims anything is deployed.
## Cards, owners, waves, flags
`flags.json` is the registry. All twelve flags default off and are read from
the `HUX_FLAGS` comma list; a flag counts only when its dependency chain is on.
| Card | Flag | Wave | Backend (Claude) | Frontend (Codex) |
|---|---|---|---|---|
| HUX-11 Shared foundation | `hux.foundation` | A | `/hux/v1` router scaffold, provenance, redaction | Bridge integration in Chat, Worker, Telegram, voice |
| HUX-01 Activity timeline | `hux.activity_timeline` | A | Event store and stream | Timeline UI, decision cards, evidence expansion |
| HUX-02 Memory control | `hux.memory_control` | A | Ledger, provenance, TTL, approval, migration | Memory page, per-message controls, export |
| HUX-05 Autonomy controls | `hux.autonomy` | A | Policy engine, grants, budgets, approvals, receipts | Ask First/Safe/Autonomous, permission matrix, stop UX |
| HUX-10 Privacy behaviour | `hux.privacy` | A | Threat model, topic policy, scoping, audit | Notices, controls, regression tests |
| HUX-12 Deployment follow-through | `hux.release_followthrough` | A | Release state machine in the Worker | Exact-SHA CI, Flux convergence, live evidence, rollback UI |
| HUX-03 Projects/chats | `hux.projects` | B | Data model, indexes, branch lineage | Navigation, search, branch graph, migration UI |
| HUX-04 Artifact workspace | `hux.artifacts` | B | Typed versions, diffs, lineage, promotion | Preview/edit/diff/share workspace |
| HUX-06 Friendly modes | `hux.friendly_modes` | B | Mode catalog and Switchyard mapping | Chooser and advanced route drawer |
| HUX-08 Research/citations | `hux.research` | B | Sources, passages, citations, notebook | Citation strip, source drawer, integrity E2E |
| HUX-07 Multimodal | `hux.multimodal` | C | Attachment lineage, transcript correction, permissions | Drag/drop, previews, annotation, consent |
| HUX-09 Onboarding | `hux.onboarding` | C | Suggestion policy and suppression | Dismissible UI, restraint tests |
## Versioning
Record schemas carry their version in the `schema` field (`hux.event.v1`).
Adding optional fields is a compatible change; anything else is a `v2` record
served beside `v1` until every surface has moved. Consumers must ignore
unknown optional fields and must not reuse record ids across versions.

View File

@ -12,6 +12,14 @@ configuration surfaces. `/terminal/` remains the raw full-screen TUI. The
community WebUI is intentionally reserved for isolated consumer chat, where
its conversation-first layout is a better fit.
## Chat UX program (HUX) contracts
The twelve-card chat improvement program codes against the contracts in
`services/hermes/contracts/hux/` (schemas, examples, `flags.json`) and the
rules in `scripts/hux_policy.py`. Read `docs/hermes_hux_contracts.md` before
adding a route to the tenant router or a panel to the WebUI bridge; every
feature is behind a `HUX_FLAGS` entry that defaults off.
## Consumer chat and Telegram
`chat.bstein.dev` uses the pinned Hermes WebUI rather than the operator

View File

@ -0,0 +1,69 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/artifact.schema.json",
"title": "HUX artifact",
"description": "Typed, versioned output (HUX-04). Content lives behind content_ref; versions are immutable and diffable; lineage records what an artifact was derived from; promotion copies a version into a project.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "type", "title", "current_version", "versions", "sensitivity", "created_at", "updated_at"],
"properties": {
"schema": {"const": "hux.artifact.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"project_id": {"$ref": "common.schema.json#/$defs/id"},
"type": {"type": "string", "enum": ["markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio"]},
"language": {"type": "string", "maxLength": 40},
"title": {"type": "string", "minLength": 1, "maxLength": 200},
"current_version": {"type": "integer", "minimum": 1},
"versions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["version", "created_at", "created_by", "content_ref"],
"properties": {
"version": {"type": "integer", "minimum": 1},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"created_by": {"$ref": "common.schema.json#/$defs/actor"},
"message_id": {"type": "string", "maxLength": 120},
"content_ref": {
"type": "object",
"additionalProperties": false,
"required": ["hash", "bytes", "mime"],
"properties": {
"hash": {"$ref": "common.schema.json#/$defs/sha256"},
"bytes": {"type": "integer", "minimum": 0},
"mime": {"type": "string", "maxLength": 120}
}
},
"diff_from": {"type": "integer", "minimum": 1},
"lineage": {
"type": "object",
"additionalProperties": false,
"required": ["artifact_id", "version"],
"properties": {
"artifact_id": {"$ref": "common.schema.json#/$defs/id"},
"version": {"type": "integer", "minimum": 1}
}
},
"note": {"type": "string", "maxLength": 200}
}
}
},
"promotion": {
"type": "object",
"additionalProperties": false,
"required": ["project_id", "version", "at"],
"properties": {
"project_id": {"$ref": "common.schema.json#/$defs/id"},
"version": {"type": "integer", "minimum": 1},
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
}

View File

@ -0,0 +1,88 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/citation.schema.json",
"title": "HUX sources, passages, citations, research notebook",
"description": "Research model (HUX-08). A source is where evidence came from; a passage is the exact excerpt; a citation ties a claim in a message to passages with an explicit support verdict; a notebook collects them per research question with assumptions and unresolved questions kept visible.",
"$defs": {
"source": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "kind", "title", "classification", "retrieved_at"],
"properties": {
"schema": {"const": "hux.source.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"kind": {"type": "string", "enum": ["web", "document", "artifact", "memory", "tool_output", "dataset"]},
"uri": {"type": "string", "maxLength": 2000},
"title": {"type": "string", "minLength": 1, "maxLength": 300},
"publisher": {"type": "string", "maxLength": 200},
"published_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"retrieved_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"classification": {"type": "string", "enum": ["primary", "secondary", "unknown"]},
"content_hash": {"$ref": "common.schema.json#/$defs/sha256"},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"}
}
},
"passage": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "source_id", "text", "hash"],
"properties": {
"schema": {"const": "hux.passage.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"source_id": {"$ref": "common.schema.json#/$defs/id"},
"text": {"type": "string", "minLength": 1, "maxLength": 4000},
"hash": {"$ref": "common.schema.json#/$defs/sha256"},
"locator": {
"type": "object",
"additionalProperties": false,
"properties": {
"page": {"type": "integer", "minimum": 1},
"selector": {"type": "string", "maxLength": 500},
"line_start": {"type": "integer", "minimum": 1},
"line_end": {"type": "integer", "minimum": 1},
"char_start": {"type": "integer", "minimum": 0},
"char_end": {"type": "integer", "minimum": 0}
}
}
}
},
"citation": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "message_id", "claim", "passage_ids", "support"],
"properties": {
"schema": {"const": "hux.citation.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"message_id": {"type": "string", "minLength": 1, "maxLength": 120},
"claim": {"type": "string", "minLength": 1, "maxLength": 1000},
"passage_ids": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"support": {"type": "string", "enum": ["supports", "partially_supports", "contradicts", "unverified"]},
"note": {"type": "string", "maxLength": 500}
}
},
"notebook": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "conversation_id", "question", "status", "source_ids", "passage_ids", "citation_ids", "assumptions", "unresolved_questions", "updated_at"],
"properties": {
"schema": {"const": "hux.research_notebook.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"question": {"type": "string", "minLength": 1, "maxLength": 1000},
"status": {"type": "string", "enum": ["open", "answered", "abandoned"]},
"source_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"passage_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"citation_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 500}},
"unresolved_questions": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 500}},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
}
},
"oneOf": [
{"$ref": "#/$defs/source"},
{"$ref": "#/$defs/passage"},
{"$ref": "#/$defs/citation"},
{"$ref": "#/$defs/notebook"}
]
}

View File

@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/common.schema.json",
"title": "HUX shared definitions",
"description": "Cross-surface primitives reused by every HUX contract. Identity, time, provenance, sensitivity and evidence are defined once here so Chat, Worker, Telegram, voice and future clients agree on them.",
"$defs": {
"id": {
"type": "string",
"description": "Opaque, prefix-typed identifier. Prefix names the record kind (evt, mem, prj, conv, art, pol, apr, rcpt, src, psg, cit, nb, sug, rel).",
"pattern": "^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$"
},
"user_ref": {
"type": "string",
"description": "Hashed Keycloak subject as already used by the chat router. Never a raw subject, email, or Telegram id.",
"pattern": "^usr_[0-9a-f]{16,64}$"
},
"timestamp": {
"type": "string",
"description": "RFC 3339 UTC timestamp with a trailing Z.",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$"
},
"sha256": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"surface": {
"type": "string",
"enum": ["chat", "worker", "telegram", "voice", "api"]
},
"provider": {
"type": "string",
"description": "Provider class, never a vendor model name. Switchyard resolves the class to a concrete target.",
"enum": ["codex", "claude", "local"]
},
"effort": {
"type": "string",
"enum": ["low", "medium", "high", "xhigh"]
},
"sensitivity": {
"type": "string",
"description": "public: safe to show anywhere; personal: user-owned but not sensitive; sensitive: health/finance/legal/relationships; restricted: credentials, minors, biometric, location traces.",
"enum": ["public", "personal", "sensitive", "restricted"]
},
"redaction": {
"type": "object",
"additionalProperties": false,
"required": ["level"],
"properties": {
"level": {"type": "string", "enum": ["none", "partial", "full"]},
"reason": {"type": "string", "maxLength": 200}
}
},
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id"],
"properties": {
"type": {"type": "string", "enum": ["user", "assistant", "tool", "system", "operator"]},
"id": {"type": "string", "minLength": 1, "maxLength": 120},
"display": {"type": "string", "maxLength": 120}
}
},
"route": {
"type": "object",
"description": "What was asked of Switchyard and what it resolved. requested is a friendly mode or a route id; resolved_target is the Switchyard target name.",
"additionalProperties": false,
"required": ["requested"],
"properties": {
"requested": {"type": "string", "minLength": 1, "maxLength": 120},
"resolved_target": {"type": "string", "maxLength": 120},
"provider": {"$ref": "#/$defs/provider"},
"effort": {"$ref": "#/$defs/effort"}
}
},
"build": {
"type": "object",
"additionalProperties": false,
"properties": {
"commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"image_digest": {"$ref": "#/$defs/sha256"}
}
},
"provenance": {
"type": "object",
"description": "Who produced a record, on which surface, under which session/run, through which route, from which build.",
"additionalProperties": false,
"required": ["surface", "actor", "recorded_at"],
"properties": {
"surface": {"$ref": "#/$defs/surface"},
"actor": {"$ref": "#/$defs/actor"},
"recorded_at": {"$ref": "#/$defs/timestamp"},
"session_id": {"type": "string", "maxLength": 120},
"conversation_id": {"$ref": "#/$defs/id"},
"message_id": {"type": "string", "maxLength": 120},
"run_id": {"type": "string", "maxLength": 120},
"route": {"$ref": "#/$defs/route"},
"build": {"$ref": "#/$defs/build"}
}
},
"evidence_ref": {
"type": "object",
"description": "Pointer to the thing that justifies a record. Never inline the payload here; the UI expands it through the owning API.",
"additionalProperties": false,
"required": ["kind", "id"],
"properties": {
"kind": {
"type": "string",
"enum": ["message", "tool_call", "tool_result", "artifact_version", "source", "passage", "memory", "approval", "run", "url", "file", "build", "flux", "pod"]
},
"id": {"type": "string", "minLength": 1, "maxLength": 200},
"uri": {"type": "string", "maxLength": 2000},
"hash": {"$ref": "#/$defs/sha256"}
}
},
"tags": {
"type": "array",
"maxItems": 32,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"}
}
}
}

View File

@ -0,0 +1,42 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/event.schema.json",
"title": "HUX activity event",
"description": "One entry in the per-conversation activity stream (HUX-01). seq is strictly monotonic per conversation; consumers resume with after_seq. Every other HUX record that changes state emits exactly one event.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "seq", "ts", "conversation_id", "kind", "summary", "provenance", "sensitivity", "redaction"],
"properties": {
"schema": {"const": "hux.event.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"seq": {"type": "integer", "minimum": 0},
"ts": {"$ref": "common.schema.json#/$defs/timestamp"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"run_id": {"type": "string", "maxLength": 120},
"parent_event_id": {"$ref": "common.schema.json#/$defs/id"},
"correlation_id": {"type": "string", "maxLength": 120},
"kind": {
"type": "string",
"enum": [
"message.user", "message.assistant",
"decision.route", "decision.plan",
"tool.call", "tool.result",
"approval.requested", "approval.resolved",
"memory.proposed", "memory.committed", "memory.forgotten",
"artifact.created", "artifact.version", "artifact.promoted",
"citation.attached",
"mode.changed",
"run.started", "run.cancelled", "run.completed", "run.failed",
"privacy.notice",
"suggestion.shown", "suggestion.dismissed",
"release.transition"
]
},
"summary": {"type": "string", "minLength": 1, "maxLength": 280},
"detail": {"type": "object", "description": "Kind-specific payload. Redacted according to redaction.level before leaving the tenant."},
"evidence": {"type": "array", "maxItems": 64, "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"redaction": {"$ref": "common.schema.json#/$defs/redaction"}
}
}

View File

@ -0,0 +1 @@
{"schema":"hux.approval.v1","id":"apr_0001aaaa","run_id":"run_9f","conversation_id":"conv_0001abcd","capability":"write_files","request":{"summary":"Write supplier-comparison.md to the workspace","risk":"low","evidence":[{"kind":"tool_call","id":"call-7"}]},"status":"approved","decision":{"choice":"session","by":{"type":"user","id":"usr_0123456789abcdef"},"at":"2026-08-23T10:00:03Z"},"requested_at":"2026-08-23T10:00:02Z","expires_at":"2026-08-23T10:10:02Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.artifact.v1","id":"art_0001aaaa","owner":"usr_0123456789abcdef","conversation_id":"conv_0001abcd","project_id":"prj_0001aaaa","type":"markdown","title":"Supplier comparison","current_version":2,"versions":[{"version":1,"created_at":"2026-08-22T10:00:00Z","created_by":{"type":"assistant","id":"hermes"},"message_id":"msg-20","content_ref":{"hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","bytes":2048,"mime":"text/markdown"}},{"version":2,"created_at":"2026-08-23T10:00:00Z","created_by":{"type":"user","id":"usr_0123456789abcdef"},"content_ref":{"hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","bytes":2100,"mime":"text/markdown"},"diff_from":1,"note":"added delivery lead times"}],"promotion":{"project_id":"prj_0001aaaa","version":2,"at":"2026-08-23T10:00:05Z"},"sensitivity":"personal","created_at":"2026-08-22T10:00:00Z","updated_at":"2026-08-23T10:00:05Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.cancel_receipt.v1","id":"rcpt_0001aaaa","run_id":"run_9f","requested_by":{"type":"user","id":"usr_0123456789abcdef"},"requested_at":"2026-08-23T10:00:04Z","acknowledged_at":"2026-08-23T10:00:04Z","completed_at":"2026-08-23T10:00:05Z","outcome":"cancelled","side_effects":[{"description":"Partial file supplier-comparison.md left in workspace","reverted":true,"evidence":{"kind":"file","id":"supplier-comparison.md"}}]}

View File

@ -0,0 +1 @@
{"schema":"hux.citation.v1","id":"cit_0001aaaa","message_id":"msg-44","claim":"Base cabinets from Example Co take three to four weeks.","passage_ids":["psg_0001aaaa"],"support":"supports"}

View File

@ -0,0 +1 @@
{"schema":"hux.conversation.v1","id":"conv_0001abcd","owner":"usr_0123456789abcdef","project_id":"prj_0001aaaa","title":"Compare cabinet suppliers","tags":["suppliers"],"pinned":false,"archived":false,"mode":"research","branch":{"parent_conversation_id":"conv_0000root","branch_point_message_id":"msg-12"},"artifact_ids":["art_0001aaaa"],"last_message_at":"2026-08-23T10:00:01Z","created_at":"2026-08-22T09:00:00Z","updated_at":"2026-08-23T10:00:01Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.event.v1","id":"evt_0001aaaa","seq":17,"ts":"2026-08-23T10:00:01Z","conversation_id":"conv_0001abcd","run_id":"run_9f","kind":"decision.route","summary":"Routed to a high-effort Claude target for a multi-step refactor","detail":{"candidates":3},"evidence":[{"kind":"run","id":"run_9f"}],"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"sensitivity":"personal","redaction":{"level":"none"}}

View File

@ -0,0 +1 @@
{"schema":"hux.memory.v1","id":"mem_0001aaaa","owner":"usr_0123456789abcdef","scope":{"level":"project","scope_id":"prj_0001aaaa"},"kind":"preference","content":"Prefers terse answers with code first.","status":"proposed","approval_mode":"ask","sensitivity":"personal","topic":"general","ttl":{"policy":"decay","decay_days":180},"source":{"kind":"message","id":"msg-42"},"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"created_at":"2026-08-23T10:00:00Z","updated_at":"2026-08-23T10:00:00Z","audit":[{"at":"2026-08-23T10:00:00Z","action":"proposed","actor":{"type":"assistant","id":"hermes"}}]}

View File

@ -0,0 +1 @@
{"schema":"hux.research_notebook.v1","id":"nb_0001aaaa","conversation_id":"conv_0001abcd","question":"Which supplier delivers fastest under budget?","status":"open","source_ids":["src_0001aaaa"],"passage_ids":["psg_0001aaaa"],"citation_ids":["cit_0001aaaa"],"assumptions":["Budget cap is unchanged at 12k"],"unresolved_questions":["Does the lead time include installation?"],"updated_at":"2026-08-23T10:00:01Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.passage.v1","id":"psg_0001aaaa","source_id":"src_0001aaaa","text":"Standard base cabinets ship in 3-4 weeks.","hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","locator":{"selector":"#lead-times","char_start":120,"char_end":160}}

View File

@ -0,0 +1 @@
{"schema":"hux.policy.v1","id":"pol_0001aaaa","owner":"usr_0123456789abcdef","scope":{"level":"conversation","scope_id":"conv_0001abcd"},"autonomy":"safe","grants":[{"capability":"network","decision":"ask","expires_at":"2026-08-24T10:00:00Z","granted_by":{"type":"user","id":"usr_0123456789abcdef"}}],"budgets":{"tokens_per_run":200000,"tool_calls_per_run":40,"wall_clock_seconds":900},"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"updated_at":"2026-08-23T10:00:00Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.privacy_notice.v1","topic":"health","conversation_id":"conv_0001abcd","text":"This looks like a health topic. It stays in this conversation and is not remembered unless you say so.","controls":["forget_this_conversation","switch_to_private","disable_memory_here","dismiss"],"shown_at":"2026-08-23T10:00:00Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.project.v1","id":"prj_0001aaaa","owner":"usr_0123456789abcdef","name":"Kitchen renovation","description":"Quotes, plans and supplier research.","tags":["home","2026"],"pinned":true,"archived":false,"default_mode":"research","created_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-23T10:00:00Z"}

View File

@ -0,0 +1 @@
{"schema":"hux.release.v1","id":"rel_0001aaaa","workload":"hermes-chat-router","feature_flags":["hux.foundation"],"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","state":"built","evidence":{"review_url":"https://gitea.bstein.dev/atlas/titan-iac/pulls/1","merge_commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","ci_build_url":"https://jenkins.bstein.dev/job/hermes-chat-router/12/","image_ref":"registry.bstein.dev/bstein/hermes-chat-router:git-d3cbeb06-build-12@sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"},"transitions":[{"from":"reviewed","to":"merged","at":"2026-08-23T08:00:00Z","by":{"type":"operator","id":"brad"},"evidence":[{"kind":"build","id":"merge"}]},{"from":"merged","to":"built","at":"2026-08-23T08:30:00Z","by":{"type":"system","id":"jenkins"},"evidence":[{"kind":"build","id":"12"}]}]}

View File

@ -0,0 +1 @@
{"schema":"hux.retention_audit.v1","id":"aud_0001aaaa","ran_at":"2026-08-23T03:00:00Z","policy_version":1,"results":[{"action":"expire_memory","count":2},{"action":"decay_topic_context","count":1},{"action":"purge_forgotten_content","count":0},{"action":"report","count":1}]}

View File

@ -0,0 +1 @@
{"schema":"hux.source.v1","id":"src_0001aaaa","kind":"web","uri":"https://example.com/cabinets","title":"Cabinet pricing 2026","publisher":"Example Co","retrieved_at":"2026-08-23T09:59:00Z","classification":"primary","content_hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}}}

View File

@ -0,0 +1 @@
{"schema":"hux.suggestion.v1","id":"sug_0001aaaa","kind":"project","trigger":{"surface":"chat","context":"after_artifact"},"title":"Keep this in a project?","body":"You have three related chats and an artifact. A project keeps them together.","action":{"type":"create_project","payload":{"prefill_title":"Kitchen renovation"}},"priority":40,"suppression":{"dismissable":true,"max_shows":2,"cooldown_seconds":86400,"never_again_supported":true}}

View File

@ -0,0 +1 @@
{"schema":"hux.suggestion_state.v1","owner":"usr_0123456789abcdef","suggestion_id":"sug_0001aaaa","shows":1,"last_shown_at":"2026-08-22T10:00:00Z","never_again":false}

View File

@ -0,0 +1,18 @@
{
"schema": "hux.flags.v1",
"env_var": "HUX_FLAGS",
"cards": [
{"card": "HUX-01", "flag": "hux.activity_timeline", "title": "Activity timeline", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["event.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; events keep accumulating on the tenant PVC and are not user-visible"},
{"card": "HUX-02", "flag": "hux.memory_control", "title": "Memory control", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["memory.schema.json"], "depends_on": ["HUX-11", "HUX-10"], "default": false, "rollback": "disable flag; ledger stays read-only, upstream memory tool unchanged"},
{"card": "HUX-03", "flag": "hux.projects", "title": "Projects and chats", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["project.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; upstream project list remains authoritative"},
{"card": "HUX-04", "flag": "hux.artifacts", "title": "Artifact workspace", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["artifact.schema.json"], "depends_on": ["HUX-11", "HUX-03"], "default": false, "rollback": "disable flag; artifacts remain downloadable files in the tenant workspace"},
{"card": "HUX-05", "flag": "hux.autonomy", "title": "Autonomy controls", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["permission.schema.json"], "depends_on": ["HUX-11", "HUX-01"], "default": false, "rollback": "disable flag; gateway approval mode reverts to the tenant default"},
{"card": "HUX-06", "flag": "hux.friendly_modes", "title": "Friendly modes", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["mode.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; routing-priority chips remain"},
{"card": "HUX-07", "flag": "hux.multimodal", "title": "Multimodal", "wave": "C", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["artifact.schema.json", "event.schema.json"], "depends_on": ["HUX-04", "HUX-05"], "default": false, "rollback": "disable flag; upstream upload path remains"},
{"card": "HUX-08", "flag": "hux.research", "title": "Research and citations", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["citation.schema.json"], "depends_on": ["HUX-11", "HUX-06"], "default": false, "rollback": "disable flag; prose citations remain"},
{"card": "HUX-09", "flag": "hux.onboarding", "title": "Onboarding", "wave": "C", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["suggestion.schema.json"], "depends_on": ["HUX-03", "HUX-06"], "default": false, "rollback": "disable flag; no suggestions shown"},
{"card": "HUX-10", "flag": "hux.privacy", "title": "Privacy behaviour", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["privacy.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; tenant isolation remains the privacy model"},
{"card": "HUX-11", "flag": "hux.foundation", "title": "Shared foundation", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["common.schema.json"], "depends_on": [], "default": false, "rollback": "disable flag; /hux/v1 returns 404 from the router"},
{"card": "HUX-12", "flag": "hux.release_followthrough", "title": "Deployment follow-through", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["release.schema.json"], "depends_on": [], "default": false, "rollback": "disable flag; release lane evidence archive remains the source of truth"}
]
}

View File

@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/memory.schema.json",
"title": "HUX memory ledger entry",
"description": "A single remembered item (HUX-02). Memory is a ledger: entries are appended, approved, expired or forgotten, never edited in place. Forgotten entries keep their audit trail but drop content.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "scope", "kind", "content", "status", "approval_mode", "sensitivity", "ttl", "source", "provenance", "created_at", "updated_at", "audit"],
"properties": {
"schema": {"const": "hux.memory.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"scope": {
"type": "object",
"additionalProperties": false,
"required": ["level"],
"properties": {
"level": {"type": "string", "enum": ["global", "project", "conversation"]},
"scope_id": {"$ref": "common.schema.json#/$defs/id"}
}
},
"kind": {"type": "string", "enum": ["preference", "fact", "instruction", "context"]},
"content": {"type": "string", "maxLength": 2000},
"status": {"type": "string", "enum": ["proposed", "active", "rejected", "expired", "forgotten"]},
"approval_mode": {"type": "string", "enum": ["automatic", "ask"]},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"topic": {"type": "string", "enum": ["general", "health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]},
"ttl": {
"type": "object",
"additionalProperties": false,
"required": ["policy"],
"properties": {
"policy": {"type": "string", "enum": ["never", "expires_at", "decay"]},
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"decay_days": {"type": "integer", "minimum": 1, "maximum": 3650}
}
},
"source": {"$ref": "common.schema.json#/$defs/evidence_ref"},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"audit": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["at", "action", "actor"],
"properties": {
"at": {"$ref": "common.schema.json#/$defs/timestamp"},
"action": {"type": "string", "enum": ["proposed", "approved", "rejected", "expired", "forgotten", "exported"]},
"actor": {"$ref": "common.schema.json#/$defs/actor"},
"note": {"type": "string", "maxLength": 200}
}
}
}
}
}

View File

@ -0,0 +1,61 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/mode.schema.json",
"title": "HUX friendly mode intent",
"description": "Stable intent contract for Fast/Thoughtful/Research/Create/Private (HUX-06). A mode never names a vendor model; it names constraints that Switchyard resolves. The advanced drawer may pin an exact route id, which must exist in the Switchyard catalog.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "mode", "label", "intent", "constraints", "switchyard"],
"properties": {
"schema": {"const": "hux.mode.v1"},
"mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
"label": {"type": "string", "minLength": 1, "maxLength": 40},
"intent": {"type": "string", "minLength": 1, "maxLength": 400},
"constraints": {
"type": "object",
"additionalProperties": false,
"required": ["providers", "local_only", "effort", "tools", "memory", "citations_required", "retention"],
"properties": {
"providers": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/provider"}},
"local_only": {"type": "boolean"},
"effort": {
"type": "object",
"additionalProperties": false,
"required": ["min", "max"],
"properties": {
"min": {"$ref": "common.schema.json#/$defs/effort"},
"max": {"$ref": "common.schema.json#/$defs/effort"}
}
},
"tools": {
"type": "object",
"additionalProperties": false,
"required": ["web", "shell", "artifacts", "delegate"],
"properties": {
"web": {"type": "string", "enum": ["required", "allowed", "denied"]},
"shell": {"type": "string", "enum": ["allowed", "denied"]},
"artifacts": {"type": "string", "enum": ["encouraged", "allowed", "denied"]},
"delegate": {"type": "string", "enum": ["allowed", "denied"]}
}
},
"memory": {
"type": "object",
"additionalProperties": false,
"required": ["read", "write"],
"properties": {"read": {"type": "boolean"}, "write": {"type": "boolean"}}
},
"citations_required": {"type": "boolean"},
"retention": {"type": "string", "enum": ["default", "ephemeral"]}
}
},
"switchyard": {
"type": "object",
"additionalProperties": false,
"required": ["route_id"],
"properties": {
"route_id": {"type": "string", "pattern": "^atlas/(auto|manual|fallback|worker)/[a-z0-9/-]+$"},
"override_route_id": {"type": "string", "pattern": "^atlas/(auto|manual)/[a-z0-9/-]+$"}
}
}
}
}

View File

@ -0,0 +1,130 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/permission.schema.json",
"title": "HUX autonomy, grants, approvals, cancellation",
"description": "Policy engine records (HUX-05). A policy fixes the autonomy level and explicit capability grants for a scope; approvals are queued items answered with the existing gateway choices (once|session|always|deny); a cancellation receipt proves what a stop actually did.",
"$defs": {
"capability": {
"type": "string",
"enum": ["read_files", "write_files", "shell", "network", "web_search", "send_message", "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy"]
},
"decision": {"type": "string", "enum": ["allow", "ask", "deny"]},
"policy": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"],
"properties": {
"schema": {"const": "hux.policy.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"scope": {
"type": "object",
"additionalProperties": false,
"required": ["level"],
"properties": {
"level": {"type": "string", "enum": ["global", "project", "conversation"]},
"scope_id": {"$ref": "common.schema.json#/$defs/id"}
}
},
"autonomy": {"type": "string", "enum": ["ask_first", "safe", "autonomous"]},
"grants": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["capability", "decision"],
"properties": {
"capability": {"$ref": "#/$defs/capability"},
"decision": {"$ref": "#/$defs/decision"},
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"granted_by": {"$ref": "common.schema.json#/$defs/actor"}
}
}
},
"budgets": {
"type": "object",
"additionalProperties": false,
"properties": {
"tokens_per_run": {"type": "integer", "minimum": 0},
"tool_calls_per_run": {"type": "integer", "minimum": 0},
"wall_clock_seconds": {"type": "integer", "minimum": 0},
"delegations_per_run": {"type": "integer", "minimum": 0}
}
},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"approval": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "run_id", "conversation_id", "capability", "request", "status", "requested_at", "expires_at"],
"properties": {
"schema": {"const": "hux.approval.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"run_id": {"type": "string", "minLength": 1, "maxLength": 120},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"capability": {"$ref": "#/$defs/capability"},
"request": {
"type": "object",
"additionalProperties": false,
"required": ["summary", "risk"],
"properties": {
"summary": {"type": "string", "minLength": 1, "maxLength": 280},
"detail": {"type": "string", "maxLength": 4000},
"risk": {"type": "string", "enum": ["low", "medium", "high"]},
"evidence": {"type": "array", "maxItems": 16, "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}}
}
},
"status": {"type": "string", "enum": ["pending", "approved", "denied", "expired", "cancelled"]},
"decision": {
"type": "object",
"additionalProperties": false,
"required": ["choice", "by", "at"],
"properties": {
"choice": {"type": "string", "enum": ["once", "session", "always", "deny"]},
"by": {"$ref": "common.schema.json#/$defs/actor"},
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"requested_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"cancellation_receipt": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "run_id", "requested_by", "requested_at", "outcome", "side_effects"],
"properties": {
"schema": {"const": "hux.cancel_receipt.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"run_id": {"type": "string", "minLength": 1, "maxLength": 120},
"requested_by": {"$ref": "common.schema.json#/$defs/actor"},
"requested_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"acknowledged_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"completed_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"outcome": {"type": "string", "enum": ["cancelled", "already_complete", "failed_to_cancel"]},
"side_effects": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["description", "reverted"],
"properties": {
"description": {"type": "string", "minLength": 1, "maxLength": 280},
"reverted": {"type": "boolean"},
"evidence": {"$ref": "common.schema.json#/$defs/evidence_ref"}
}
}
}
}
}
},
"oneOf": [
{"$ref": "#/$defs/policy"},
{"$ref": "#/$defs/approval"},
{"$ref": "#/$defs/cancellation_receipt"}
]
}

View File

@ -0,0 +1,94 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/privacy.schema.json",
"title": "HUX privacy policy, topic scoping and retention",
"description": "Sensitive-topic behaviour (HUX-10). The policy names each topic class, whether memory may be written from it, how long conversation-scoped context survives, and which just-in-time notice the surface must show. A retention audit record proves the rules ran.",
"$defs": {
"topic": {"type": "string", "enum": ["health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]},
"policy": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "version", "topics", "topic_scoping", "retention_audit"],
"properties": {
"schema": {"const": "hux.privacy_policy.v1"},
"version": {"type": "integer", "minimum": 1},
"topics": {
"type": "array",
"minItems": 8,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["topic", "sensitivity", "memory_write", "decay_days", "notice"],
"properties": {
"topic": {"$ref": "#/$defs/topic"},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"memory_write": {"type": "string", "enum": ["ask", "deny"]},
"decay_days": {"type": "integer", "minimum": 1, "maximum": 365},
"notice": {"type": "string", "minLength": 1, "maxLength": 280}
}
}
},
"topic_scoping": {
"type": "object",
"additionalProperties": false,
"required": ["scope_to_conversation", "cross_surface_sharing"],
"properties": {
"scope_to_conversation": {"const": true},
"cross_surface_sharing": {"type": "string", "enum": ["never", "same_owner_only"]}
}
},
"retention_audit": {
"type": "object",
"additionalProperties": false,
"required": ["interval_days", "actions"],
"properties": {
"interval_days": {"type": "integer", "minimum": 1, "maximum": 30},
"actions": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]}}
}
}
}
},
"notice": {
"type": "object",
"description": "Just-in-time notice a surface shows when a sensitive topic is detected. Emitted as event kind privacy.notice.",
"additionalProperties": false,
"required": ["schema", "topic", "conversation_id", "text", "controls", "shown_at"],
"properties": {
"schema": {"const": "hux.privacy_notice.v1"},
"topic": {"$ref": "#/$defs/topic"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"text": {"type": "string", "minLength": 1, "maxLength": 280},
"controls": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss"]}},
"shown_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"audit": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "ran_at", "policy_version", "results"],
"properties": {
"schema": {"const": "hux.retention_audit.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"ran_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"policy_version": {"type": "integer", "minimum": 1},
"results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["action", "count"],
"properties": {
"action": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]},
"count": {"type": "integer", "minimum": 0}
}
}
}
}
}
},
"oneOf": [
{"$ref": "#/$defs/policy"},
{"$ref": "#/$defs/notice"},
{"$ref": "#/$defs/audit"}
]
}

View File

@ -0,0 +1,69 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/project.schema.json",
"title": "HUX projects and conversations",
"description": "Organisation model for chats (HUX-03): projects own conversations and promoted artifacts; conversations carry tags, pins and branch lineage. Search indexes the fields listed in $defs/search_index.",
"$defs": {
"project": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "name", "tags", "pinned", "archived", "created_at", "updated_at"],
"properties": {
"schema": {"const": "hux.project.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"description": {"type": "string", "maxLength": 2000},
"tags": {"$ref": "common.schema.json#/$defs/tags"},
"pinned": {"type": "boolean"},
"archived": {"type": "boolean"},
"default_mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
"memory_scope_id": {"$ref": "common.schema.json#/$defs/id"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"conversation": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "title", "tags", "pinned", "archived", "artifact_ids", "created_at", "updated_at"],
"properties": {
"schema": {"const": "hux.conversation.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"project_id": {"$ref": "common.schema.json#/$defs/id"},
"title": {"type": "string", "minLength": 1, "maxLength": 200},
"tags": {"$ref": "common.schema.json#/$defs/tags"},
"pinned": {"type": "boolean"},
"archived": {"type": "boolean"},
"mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
"branch": {
"type": "object",
"additionalProperties": false,
"required": ["parent_conversation_id", "branch_point_message_id"],
"properties": {
"parent_conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"branch_point_message_id": {"type": "string", "minLength": 1, "maxLength": 120}
}
},
"artifact_ids": {"type": "array", "maxItems": 500, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"last_message_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"search_index": {
"type": "object",
"description": "Fields the tenant indexes; the UI may not assume anything else is searchable.",
"additionalProperties": false,
"required": ["conversation"],
"properties": {
"conversation": {"type": "array", "items": {"type": "string", "enum": ["title", "tags", "message_text", "artifact_titles", "project_name"]}}
}
}
},
"oneOf": [
{"$ref": "#/$defs/project"},
{"$ref": "#/$defs/conversation"}
]
}

View File

@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/release.schema.json",
"title": "HUX release state",
"description": "Deployment follow-through (HUX-12). One record per reviewed commit per workload. The state machine is linear and every forward transition requires named evidence, so merged, built and deployed can never be conflated: built needs a Harbor digest, deployed needs the Flux revision, converged needs the running pod digest to equal the built digest, live_verified needs a health probe.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "workload", "commit", "state", "evidence", "transitions"],
"properties": {
"schema": {"const": "hux.release.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"workload": {"type": "string", "enum": ["hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"]},
"feature_flags": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^hux\\.[a-z_]+$"}},
"commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"state": {"type": "string", "enum": ["reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified", "rolled_back"]},
"evidence": {
"type": "object",
"additionalProperties": false,
"properties": {
"review_url": {"type": "string", "maxLength": 500},
"merge_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"ci_build_url": {"type": "string", "maxLength": 500},
"image_ref": {"type": "string", "pattern": "^[a-z0-9./-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$"},
"image_digest": {"$ref": "common.schema.json#/$defs/sha256"},
"harbor_digest": {"$ref": "common.schema.json#/$defs/sha256"},
"flux_revision": {"type": "string", "pattern": "^main@sha1:[0-9a-f]{40}$"},
"pod_digest": {"$ref": "common.schema.json#/$defs/sha256"},
"health_check": {
"type": "object",
"additionalProperties": false,
"required": ["url", "status", "at"],
"properties": {
"url": {"type": "string", "maxLength": 500},
"status": {"type": "string", "enum": ["pass", "fail"]},
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
}
},
"rollback_target": {"$ref": "common.schema.json#/$defs/sha256"}
}
},
"transitions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["from", "to", "at", "by"],
"properties": {
"from": {"type": "string", "enum": ["reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified", "rolled_back"]},
"to": {"type": "string", "enum": ["reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified", "rolled_back"]},
"at": {"$ref": "common.schema.json#/$defs/timestamp"},
"by": {"$ref": "common.schema.json#/$defs/actor"},
"evidence": {"type": "array", "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}}
}
}
}
}
}

View File

@ -0,0 +1,69 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/suggestion.schema.json",
"title": "HUX onboarding suggestion and suppression state",
"description": "Restrained onboarding (HUX-09). A suggestion declares exactly when it may appear and how often; suppression state is per user and wins over the suggestion. Nothing is shown without a trigger, and every suggestion is dismissable.",
"$defs": {
"suggestion": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "kind", "trigger", "title", "body", "priority", "suppression"],
"properties": {
"schema": {"const": "hux.suggestion.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"kind": {"type": "string", "enum": ["workflow", "project", "feature", "tip"]},
"trigger": {
"type": "object",
"additionalProperties": false,
"required": ["surface", "context"],
"properties": {
"surface": {"$ref": "common.schema.json#/$defs/surface"},
"context": {"type": "string", "enum": ["first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle"]}
}
},
"title": {"type": "string", "minLength": 1, "maxLength": 80},
"body": {"type": "string", "minLength": 1, "maxLength": 280},
"action": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {"type": "string", "enum": ["open_mode", "create_project", "open_memory", "open_artifacts", "start_workflow", "none"]},
"payload": {"type": "object"}
}
},
"priority": {"type": "integer", "minimum": 0, "maximum": 100},
"suppression": {
"type": "object",
"additionalProperties": false,
"required": ["dismissable", "max_shows", "cooldown_seconds", "never_again_supported"],
"properties": {
"dismissable": {"const": true},
"max_shows": {"type": "integer", "minimum": 1, "maximum": 5},
"cooldown_seconds": {"type": "integer", "minimum": 3600},
"never_again_supported": {"const": true}
}
}
}
},
"state": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "owner", "suggestion_id", "shows", "never_again"],
"properties": {
"schema": {"const": "hux.suggestion_state.v1"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"suggestion_id": {"$ref": "common.schema.json#/$defs/id"},
"shows": {"type": "integer", "minimum": 0},
"last_shown_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"dismissed_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"acted_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"never_again": {"type": "boolean"}
}
}
},
"oneOf": [
{"$ref": "#/$defs/suggestion"},
{"$ref": "#/$defs/state"}
]
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,237 @@
"""Load and validate the HUX (Hermes user-experience) contract schemas.
The schemas under ``services/hermes/contracts/hux`` are plain JSON Schema
2020-12 so browser and Go consumers can validate with their usual libraries.
CI has no ``jsonschema`` package, so this module carries a small validator for
the keyword subset the contracts actually use. Unsupported keywords fail
loudly rather than silently passing.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux"
SCHEMA_FILES = (
"common.schema.json",
"event.schema.json",
"memory.schema.json",
"project.schema.json",
"artifact.schema.json",
"permission.schema.json",
"mode.schema.json",
"citation.schema.json",
"suggestion.schema.json",
"privacy.schema.json",
"release.schema.json",
)
SUPPORTED_KEYWORDS = frozenset(
{
"$schema", "$id", "$defs", "$ref", "title", "description",
"type", "const", "enum", "required", "properties",
"additionalProperties", "items", "minItems", "maxItems",
"uniqueItems", "minLength", "maxLength", "pattern",
"minimum", "maximum", "oneOf",
}
)
_TYPE_CHECKS = {
"object": lambda v: isinstance(v, dict),
"array": lambda v: isinstance(v, list),
"string": lambda v: isinstance(v, str),
"boolean": lambda v: isinstance(v, bool),
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"null": lambda v: v is None,
}
class ContractError(ValueError):
"""Raised when a schema uses something this validator does not support."""
def load_schema(name: str, directory: Path = CONTRACT_DIR) -> dict[str, Any]:
"""Read one schema file by name."""
return json.loads((directory / name).read_text(encoding="utf-8"))
def load_all(directory: Path = CONTRACT_DIR) -> dict[str, dict[str, Any]]:
"""Read every contract schema keyed by file name."""
return {name: load_schema(name, directory) for name in SCHEMA_FILES}
def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]:
"""Read the feature flag registry."""
return json.loads((directory / "flags.json").read_text(encoding="utf-8"))
def _walk(node: Any, path: str, problems: list[str]) -> None:
if isinstance(node, dict):
for key, value in node.items():
if path.endswith("/properties") or path.endswith("/$defs"):
_walk(value, f"{path}/{key}", problems)
continue
if key not in SUPPORTED_KEYWORDS:
problems.append(f"{path}/{key}")
continue
_walk(value, f"{path}/{key}", problems)
elif isinstance(node, list):
for index, value in enumerate(node):
_walk(value, f"{path}/{index}", problems)
def unsupported_keywords(schema: dict[str, Any]) -> list[str]:
"""Return JSON-pointer style paths of keywords the validator ignores."""
problems: list[str] = []
_walk(schema, "#", problems)
return problems
def _resolve_ref(ref: str, current: str, schemas: dict[str, dict[str, Any]]) -> tuple[dict[str, Any], str]:
file_part, _, pointer = ref.partition("#")
file_name = file_part or current
if file_name not in schemas:
raise ContractError(f"unknown schema reference {ref!r}")
node: Any = schemas[file_name]
for token in [t for t in pointer.split("/") if t]:
if not isinstance(node, dict) or token not in node:
raise ContractError(f"unresolvable pointer {ref!r}")
node = node[token]
return node, file_name
def _check_type(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> bool:
expected = schema.get("type")
if expected is None:
return True
if expected not in _TYPE_CHECKS:
raise ContractError(f"unsupported type {expected!r} at {path}")
if not _TYPE_CHECKS[expected](value):
errors.append(f"{path}: expected {expected}")
return False
return True
def _check_scalars(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> None:
if "const" in schema and value != schema["const"]:
errors.append(f"{path}: expected constant {schema['const']!r}")
if "enum" in schema and value not in schema["enum"]:
errors.append(f"{path}: {value!r} not in enum")
if isinstance(value, str):
if "minLength" in schema and len(value) < schema["minLength"]:
errors.append(f"{path}: shorter than {schema['minLength']}")
if "maxLength" in schema and len(value) > schema["maxLength"]:
errors.append(f"{path}: longer than {schema['maxLength']}")
if "pattern" in schema and not re.search(schema["pattern"], value):
errors.append(f"{path}: does not match {schema['pattern']!r}")
if isinstance(value, (int, float)) and not isinstance(value, bool):
if "minimum" in schema and value < schema["minimum"]:
errors.append(f"{path}: below minimum {schema['minimum']}")
if "maximum" in schema and value > schema["maximum"]:
errors.append(f"{path}: above maximum {schema['maximum']}")
def _check_object(schema, value, path, errors, current, schemas) -> None:
properties = schema.get("properties", {})
for key in schema.get("required", []):
if key not in value:
errors.append(f"{path}: missing required {key!r}")
for key, item in value.items():
if key in properties:
_validate(properties[key], item, f"{path}/{key}", errors, current, schemas)
elif schema.get("additionalProperties") is False:
errors.append(f"{path}: unexpected property {key!r}")
def _check_array(schema, value, path, errors, current, schemas) -> None:
if "minItems" in schema and len(value) < schema["minItems"]:
errors.append(f"{path}: fewer than {schema['minItems']} items")
if "maxItems" in schema and len(value) > schema["maxItems"]:
errors.append(f"{path}: more than {schema['maxItems']} items")
if schema.get("uniqueItems"):
seen = [json.dumps(item, sort_keys=True) for item in value]
if len(set(seen)) != len(seen):
errors.append(f"{path}: items are not unique")
if "items" in schema:
for index, item in enumerate(value):
_validate(schema["items"], item, f"{path}/{index}", errors, current, schemas)
def _check_one_of(schema, value, path, errors, current, schemas) -> None:
attempts: list[list[str]] = []
for option in schema["oneOf"]:
sub: list[str] = []
_validate(option, value, path, sub, current, schemas)
attempts.append(sub)
matches = sum(not sub for sub in attempts)
if matches != 1:
errors.append(f"{path}: matched {matches} oneOf branches, expected exactly 1")
if matches == 0:
errors.extend(min(attempts, key=len))
def _validate(schema, value, path, errors, current, schemas) -> None:
if "$ref" in schema:
target, file_name = _resolve_ref(schema["$ref"], current, schemas)
_validate(target, value, path, errors, file_name, schemas)
return
if not _check_type(schema, value, path, errors):
return
_check_scalars(schema, value, path, errors)
if isinstance(value, dict):
_check_object(schema, value, path, errors, current, schemas)
if isinstance(value, list):
_check_array(schema, value, path, errors, current, schemas)
if "oneOf" in schema:
_check_one_of(schema, value, path, errors, current, schemas)
def validate(
schema_name: str,
value: Any,
schemas: dict[str, dict[str, Any]] | None = None,
pointer: str = "",
) -> list[str]:
"""Validate ``value`` against a schema file, or a ``#/$defs/...`` pointer inside it.
Returns a list of human-readable problems; an empty list means valid.
"""
schemas = schemas or load_all()
schema, file_name = _resolve_ref(f"{schema_name}#{pointer}", schema_name, schemas)
errors: list[str] = []
_validate(schema, value, "$", errors, file_name, schemas)
return errors
def record_schema_names(schemas: dict[str, dict[str, Any]] | None = None) -> dict[str, str]:
"""Map every ``schema`` constant (e.g. ``hux.event.v1``) to its file name."""
schemas = schemas or load_all()
found: dict[str, str] = {}
def visit(node: Any, file_name: str) -> None:
if isinstance(node, dict):
const = node.get("properties", {}).get("schema", {}).get("const")
if isinstance(const, str):
found[const] = file_name
for child in node.values():
visit(child, file_name)
elif isinstance(node, list):
for child in node:
visit(child, file_name)
for file_name, schema in schemas.items():
visit(schema, file_name)
return found
def validate_record(value: Any, schemas: dict[str, dict[str, Any]] | None = None) -> list[str]:
"""Validate a record by its own ``schema`` field."""
schemas = schemas or load_all()
if not isinstance(value, dict) or not isinstance(value.get("schema"), str):
return ["$: record has no string 'schema' field"]
file_name = record_schema_names(schemas).get(value["schema"])
if file_name is None:
return [f"$: unknown record schema {value['schema']!r}"]
return validate(file_name, value, schemas)

View File

@ -0,0 +1,313 @@
"""Governance rules behind the HUX contracts.
Pure functions only: state machines for memory, approvals and releases, the
autonomy capability matrix, the friendly-mode catalog and its Switchyard
mapping, the sensitive-topic policy, suggestion gating and the feature-flag
registry. The chat router and the WebUI both consume these rules; keeping them
here means one implementation is tested and the other only serialises it.
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from hux_contracts import load_flags
EFFORT_ORDER = ("low", "medium", "high", "xhigh")
MEMORY_TRANSITIONS: dict[str, frozenset[str]] = {
"proposed": frozenset({"active", "rejected"}),
"active": frozenset({"expired", "forgotten"}),
"expired": frozenset({"forgotten"}),
"rejected": frozenset(),
"forgotten": frozenset(),
}
APPROVAL_TRANSITIONS: dict[str, frozenset[str]] = {
"pending": frozenset({"approved", "denied", "expired", "cancelled"}),
"approved": frozenset(),
"denied": frozenset(),
"expired": frozenset(),
"cancelled": frozenset(),
}
RELEASE_ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified")
RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = {
"merged": ("merge_commit",),
"built": ("ci_build_url", "image_digest"),
"verified": ("harbor_digest",),
"deployed": ("flux_revision",),
"converged": ("pod_digest",),
"live_verified": ("health_check",),
"rolled_back": ("rollback_target",),
}
CAPABILITIES = (
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
)
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_EXTERNAL = frozenset({"network", "deploy"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
"fast": {
"label": "Fast",
"intent": "Quick answers and small edits. Lowest latency wins; depth is not expected.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "low", "max": "medium"},
"tools": {"web": "allowed", "shell": "denied", "artifacts": "allowed", "delegate": "denied"},
"memory": {"read": True, "write": True}, "citations_required": False, "retention": "default",
"route_id": "atlas/auto/fast",
},
"thoughtful": {
"label": "Thoughtful",
"intent": "Careful reasoning on one problem. Takes longer, checks its own work, may run tools.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "medium", "max": "xhigh"},
"tools": {"web": "allowed", "shell": "allowed", "artifacts": "allowed", "delegate": "allowed"},
"memory": {"read": True, "write": True}, "citations_required": False, "retention": "default",
"route_id": "atlas/auto/deep",
},
"research": {
"label": "Research",
"intent": "Find, read and cite sources. Every factual claim carries a citation with a support verdict; assumptions and open questions stay visible.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "high", "max": "xhigh"},
"tools": {"web": "required", "shell": "denied", "artifacts": "allowed", "delegate": "allowed"},
"memory": {"read": True, "write": True}, "citations_required": True, "retention": "default",
"route_id": "atlas/auto/deep",
},
"create": {
"label": "Create",
"intent": "Produce and iterate on a durable artifact: documents, code, pages, images. Versions are kept and diffable.",
"providers": ["codex", "claude"], "local_only": False,
"effort": {"min": "medium", "max": "high"},
"tools": {"web": "allowed", "shell": "allowed", "artifacts": "encouraged", "delegate": "allowed"},
"memory": {"read": True, "write": True}, "citations_required": False, "retention": "default",
"route_id": "atlas/auto/balanced",
},
"private": {
"label": "Private",
"intent": "Nothing leaves the cluster. Local model only, no memory writes, no web, conversation not retained beyond the session.",
"providers": ["local"], "local_only": True,
"effort": {"min": "low", "max": "medium"},
"tools": {"web": "denied", "shell": "denied", "artifacts": "allowed", "delegate": "denied"},
"memory": {"read": False, "write": False}, "citations_required": False, "retention": "ephemeral",
"route_id": "atlas/manual/local/qwen-14b",
},
}
PRIVACY_TOPICS: dict[str, dict[str, Any]] = {
"health": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30},
"finance": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30},
"legal": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30},
"relationships": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 14},
"credentials": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 1},
"minors": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 7},
"location": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 7},
"biometric": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 1},
}
_NOTICE = "This looks like a {topic} topic. It stays in this conversation and is not remembered unless you say so."
def transition_allowed(table: dict[str, frozenset[str]], current: str, target: str) -> bool:
"""True when ``current -> target`` is a legal move in ``table``."""
return target in table.get(current, frozenset())
def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
"""Rules the schema cannot express: sensitivity drives approval, TTL and topic."""
problems: list[str] = []
sensitivity = entry.get("sensitivity")
topic = entry.get("topic", "general")
if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}:
problems.append("restricted content may not be remembered")
if sensitivity == "sensitive" and entry.get("approval_mode") != "ask":
problems.append("sensitive memory requires approval_mode=ask")
if sensitivity == "sensitive" and entry.get("ttl", {}).get("policy") == "never":
problems.append("sensitive memory must expire or decay")
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and entry.get("status") != "rejected":
problems.append(f"topic {topic} may not be written to memory")
ttl = entry.get("ttl", {})
if ttl.get("policy") == "expires_at" and "expires_at" not in ttl:
problems.append("ttl.policy=expires_at requires expires_at")
if ttl.get("policy") == "decay" and "decay_days" not in ttl:
problems.append("ttl.policy=decay requires decay_days")
if entry.get("status") == "forgotten" and entry.get("content"):
problems.append("forgotten entries must drop their content")
return problems
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""Autonomy level -> capability -> allow|ask|deny. Deploy always asks."""
matrix: dict[str, dict[str, str]] = {}
for level in ("ask_first", "safe", "autonomous"):
row: dict[str, str] = {}
for capability in CAPABILITIES:
if capability == "deploy":
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
elif capability in _MUTATING:
row[capability] = "ask" if level != "autonomous" else "allow"
elif capability in _EXTERNAL:
row[capability] = {"ask_first": "ask", "safe": "deny", "autonomous": "allow"}[level]
matrix[level] = row
return matrix
def effective_decision(policy: dict[str, Any], capability: str, now: datetime | None = None) -> str:
"""Resolve one capability under a policy: explicit unexpired grant beats the matrix, deny beats all."""
now = now or datetime.now(timezone.utc)
matrix = default_capability_matrix()[policy["autonomy"]]
decision = matrix[capability]
for grant in policy.get("grants", []):
if grant["capability"] != capability:
continue
expires = grant.get("expires_at")
if expires and datetime.fromisoformat(expires.replace("Z", "+00:00")) <= now:
continue
if grant["decision"] == "deny":
return "deny"
decision = grant["decision"]
if capability == "deploy" and decision == "allow":
return "ask"
return decision
def release_transition_problems(record: dict[str, Any], target: str) -> list[str]:
"""Why ``record`` may not move to ``target``. Empty means it may."""
current = record["state"]
problems: list[str] = []
if target == "rolled_back":
if current in {"reviewed", "merged"}:
problems.append("nothing to roll back before an image exists")
elif current == "rolled_back":
problems.append("a rolled back release is terminal; open a new release record")
elif RELEASE_ORDER.index(target) != RELEASE_ORDER.index(current) + 1:
problems.append(f"{current} -> {target} skips or reverses the release order")
evidence = record.get("evidence", {})
for key in RELEASE_EVIDENCE.get(target, ()):
if key not in evidence:
problems.append(f"{target} requires evidence.{key}")
if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"):
problems.append("harbor_digest must equal image_digest")
if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"):
problems.append("pod_digest must equal image_digest")
if target == "live_verified" and evidence.get("health_check", {}).get("status") != "pass":
problems.append("health_check must pass")
return problems
def mode_contract(mode: str, override_route_id: str | None = None) -> dict[str, Any]:
"""Serialise one friendly mode as a ``hux.mode.v1`` record."""
spec = MODE_CATALOG[mode]
switchyard: dict[str, Any] = {"route_id": spec["route_id"]}
if override_route_id:
if spec["local_only"] and "/local/" not in override_route_id:
raise ValueError("private mode cannot be overridden to a hosted route")
switchyard["override_route_id"] = override_route_id
return {
"schema": "hux.mode.v1",
"mode": mode,
"label": spec["label"],
"intent": spec["intent"],
"constraints": {
key: spec[key]
for key in ("providers", "local_only", "effort", "tools", "memory", "citations_required", "retention")
},
"switchyard": switchyard,
}
def effort_within(mode: str, effort: str) -> bool:
"""True when ``effort`` sits inside the mode's allowed band."""
band = MODE_CATALOG[mode]["effort"]
index = EFFORT_ORDER.index(effort)
return EFFORT_ORDER.index(band["min"]) <= index <= EFFORT_ORDER.index(band["max"])
def privacy_policy(version: int = 1) -> dict[str, Any]:
"""Serialise the sensitive-topic policy as a ``hux.privacy_policy.v1`` record."""
return {
"schema": "hux.privacy_policy.v1",
"version": version,
"topics": [
{"topic": topic, **rule, "notice": _NOTICE.format(topic=topic)}
for topic, rule in PRIVACY_TOPICS.items()
],
"topic_scoping": {"scope_to_conversation": True, "cross_surface_sharing": "never"},
"retention_audit": {
"interval_days": 1,
"actions": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"],
},
}
def suggestion_allowed(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> bool:
"""Suppression state wins: never-again, dismissed, exhausted shows, or cooling down all block."""
if state is None:
return True
if state.get("never_again") or state.get("dismissed_at") or state.get("acted_at"):
return False
rules = suggestion["suppression"]
if state.get("shows", 0) >= rules["max_shows"]:
return False
last = state.get("last_shown_at")
if last:
shown = datetime.fromisoformat(last.replace("Z", "+00:00"))
if now - shown < timedelta(seconds=rules["cooldown_seconds"]):
return False
return True
def flag_registry() -> dict[str, dict[str, Any]]:
"""Card id -> registry entry."""
return {card["card"]: card for card in load_flags()["cards"]}
def enabled_flags(environ: dict[str, str] | None = None) -> set[str]:
"""Flags switched on through the HUX_FLAGS comma list; unknown names are ignored."""
environ = os.environ if environ is None else environ
known = {card["flag"] for card in flag_registry().values()}
raw = environ.get(load_flags()["env_var"], "")
return {item.strip() for item in raw.split(",") if item.strip() in known}
def flag_enabled(flag: str, environ: dict[str, str] | None = None) -> bool:
"""A flag counts only when it and every card it depends on are enabled."""
registry = flag_registry()
by_flag = {card["flag"]: card for card in registry.values()}
if flag not in by_flag:
return False
on = enabled_flags(environ)
pending = [by_flag[flag]]
while pending:
card = pending.pop()
if card["flag"] not in on:
return False
pending.extend(registry[dep] for dep in card["depends_on"])
return True
def dependency_order() -> list[str]:
"""Cards in an order that satisfies depends_on; raises on cycles."""
registry = flag_registry()
done: list[str] = []
visiting: set[str] = set()
def visit(card_id: str) -> None:
if card_id in done:
return
if card_id in visiting:
raise ValueError(f"dependency cycle through {card_id}")
visiting.add(card_id)
for dep in registry[card_id]["depends_on"]:
visit(dep)
visiting.discard(card_id)
done.append(card_id)
for card_id in sorted(registry):
visit(card_id)
return done

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/voice_route_preflight.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",
@ -102,13 +103,16 @@
"scripts/ops/hermes_handoff_redaction.py",
"scripts/ops/hermes_handoff_rules.py",
"scripts/ops/hermes_handoff_run.py",
"testing/quality_handoff_mutation.py"
"testing/quality_handoff_mutation.py",
"services/hermes/scripts/hux_contracts.py",
"services/hermes/scripts/hux_policy.py"
],
"lint_paths": [
"ci/scripts/hermes_image_release.py",
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
"services/hermes/scripts/hermes_image_release_status.py",
"services/hermes/scripts/voice_route_preflight.py",
"services/hermes/scripts/jenkins_image_build_trigger.py",
"ci/scripts/publish_test_metrics.py",
"ci/scripts/publish_test_metrics_quality.py",
@ -275,7 +279,8 @@
"services/hermes/scripts/node_account_io.py",
"services/hermes/scripts/stage_runtime_access.py",
"services/hermes/scripts/node_polkit_audit.py",
"scripts/ops/hermes_handoff_*.py"
"scripts/ops/hermes_handoff_*.py",
"services/hermes/scripts/hux_*.py"
],
"naming_rules": [
{
@ -319,6 +324,7 @@
"minimum_percent": 95.0,
"minimum_branch_percent": 95.0,
"branch_tracked_files": [
"services/hermes/scripts/voice_route_preflight.py",
"scripts/ops/hermes_handoff_acceptance.py",
"scripts/ops/hermes_handoff_arming.py",
"scripts/ops/hermes_handoff_catalog.py",
@ -340,6 +346,7 @@
"testing/quality_handoff_mutation.py"
],
"tracked_files": [
"services/hermes/scripts/voice_route_preflight.py",
"ci/scripts/hermes_image_release.py",
"dockerfiles/hermes-kaniko-heredoc-runner.py",
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",

View File

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

View File

@ -0,0 +1,324 @@
"""Contract tests for the HUX schemas and the governance rules behind them.
Every schema must stay inside the keyword subset the in-repo validator
understands, every shipped example must validate, every friendly mode must
resolve to a Switchyard route that really exists, and the state machines must
refuse the transitions that would let merged/built/deployed be confused.
"""
from __future__ import annotations
import copy
import importlib.util
import json
import sys
import tomllib
from datetime import datetime, timezone
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "services" / "hermes" / "scripts"
CONTRACTS = ROOT / "services" / "hermes" / "contracts" / "hux"
def _load(name: str):
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
contracts = _load("hux_contracts")
policy = _load("hux_policy")
SCHEMAS = contracts.load_all()
EXAMPLES = {path.stem: json.loads(path.read_text()) for path in sorted((CONTRACTS / "examples").glob("*.json"))}
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=timezone.utc)
def _switchyard_route_ids() -> set[str]:
manifest = next(iter(yaml.safe_load_all((ROOT / "services/hermes/switchyard-configmap.yaml").read_text())))
routes = tomllib.loads(manifest["data"]["routes.toml"])["routes"]
return {route["id"] for route in routes.values()}
# --- schema hygiene -----------------------------------------------------------
@pytest.mark.parametrize("name", contracts.SCHEMA_FILES)
def test_schema_uses_only_supported_keywords(name):
assert contracts.unsupported_keywords(SCHEMAS[name]) == []
assert SCHEMAS[name]["$id"].endswith(name)
def test_every_schema_file_is_registered():
on_disk = {path.name for path in CONTRACTS.glob("*.schema.json")}
assert on_disk == set(contracts.SCHEMA_FILES)
def test_walker_flags_unknown_keywords():
assert contracts.unsupported_keywords({"type": "object", "properties": {"x": {"format": "email"}}}) == ["#/properties/x/format"]
# --- examples -----------------------------------------------------------------
@pytest.mark.parametrize("name", sorted(EXAMPLES))
def test_example_validates_by_its_schema_field(name):
assert contracts.validate_record(EXAMPLES[name], SCHEMAS) == []
def test_every_record_schema_has_an_example():
covered = {example["schema"] for example in EXAMPLES.values()}
covered |= {"hux.mode.v1", "hux.privacy_policy.v1"}
assert set(contracts.record_schema_names(SCHEMAS)) == covered
def test_validate_record_rejects_unknown_or_missing_schema():
assert contracts.validate_record({"schema": "hux.nope.v1"}, SCHEMAS) == ["$: unknown record schema 'hux.nope.v1'"]
assert contracts.validate_record(["not", "a", "record"], SCHEMAS)[0].startswith("$: record has no")
def test_default_loading_paths_work():
assert contracts.validate_record(EXAMPLES["event"]) == []
assert contracts.validate("event.schema.json", EXAMPLES["event"]) == []
assert "HUX-01" in {card["card"] for card in contracts.load_flags()["cards"]}
# --- validator keyword coverage ----------------------------------------------
def _broken(name: str, mutate) -> list[str]:
record = copy.deepcopy(EXAMPLES[name])
mutate(record)
return contracts.validate_record(record, SCHEMAS)
def test_validator_catches_each_constraint_class():
assert any("expected integer" in e for e in _broken("event", lambda r: r.update(seq=True)))
assert any("below minimum" in e for e in _broken("event", lambda r: r.update(seq=-1)))
assert any("not in enum" in e for e in _broken("event", lambda r: r.update(kind="tool.dance")))
assert any("unexpected property" in e for e in _broken("event", lambda r: r.update(extra=1)))
assert any("missing required" in e for e in _broken("event", lambda r: r.pop("summary")))
assert any("longer than" in e for e in _broken("event", lambda r: r.update(summary="x" * 281)))
assert any("shorter than" in e for e in _broken("event", lambda r: r.update(summary="")))
assert any("does not match" in e for e in _broken("event", lambda r: r.update(ts="yesterday")))
wrong_const = copy.deepcopy(EXAMPLES["event"])
wrong_const["schema"] = "hux.event.v0"
assert any("expected constant" in e for e in contracts.validate("event.schema.json", wrong_const, SCHEMAS))
assert any("more than" in e for e in _broken("event", lambda r: r.update(evidence=[{"kind": "run", "id": "r"}] * 65)))
assert any("not unique" in e for e in _broken("project", lambda r: r.update(tags=["a", "a"])))
assert any("fewer than" in e for e in _broken("memory", lambda r: r.update(audit=[])))
assert any("above maximum" in e for e in _broken("suggestion", lambda r: r.update(priority=101)))
assert any("expected object" in e for e in _broken("event", lambda r: r.update(provenance="x")))
def test_one_of_requires_exactly_one_branch():
ambiguous = {"schema": "hux.policy.v1"}
errors = contracts.validate("permission.schema.json", ambiguous, SCHEMAS)
assert any("matched 0 oneOf branches" in e for e in errors)
def test_pointer_validation_and_bad_refs():
assert contracts.validate("permission.schema.json", EXAMPLES["approval"], SCHEMAS, "/$defs/approval") == []
with pytest.raises(contracts.ContractError):
contracts.validate("permission.schema.json", {}, SCHEMAS, "/$defs/missing")
with pytest.raises(contracts.ContractError):
contracts._resolve_ref("nowhere.schema.json#/x", "event.schema.json", SCHEMAS)
with pytest.raises(contracts.ContractError):
contracts._validate({"type": "date"}, "x", "$", [], "event.schema.json", SCHEMAS)
# --- memory ledger -------------------------------------------------------------
def test_memory_state_machine_is_append_only():
allowed = {(a, b) for a, bs in policy.MEMORY_TRANSITIONS.items() for b in bs}
assert allowed == {("proposed", "active"), ("proposed", "rejected"), ("active", "expired"), ("active", "forgotten"), ("expired", "forgotten")}
assert policy.transition_allowed(policy.MEMORY_TRANSITIONS, "proposed", "active")
assert not policy.transition_allowed(policy.MEMORY_TRANSITIONS, "forgotten", "active")
assert not policy.transition_allowed(policy.MEMORY_TRANSITIONS, "unknown", "active")
def test_memory_policy_rules():
base = EXAMPLES["memory"]
assert policy.memory_policy_violations(base) == []
restricted = {**base, "sensitivity": "restricted"}
assert "restricted content may not be remembered" in policy.memory_policy_violations(restricted)
lax = {**base, "sensitivity": "sensitive", "approval_mode": "automatic", "ttl": {"policy": "never"}}
problems = policy.memory_policy_violations(lax)
assert "sensitive memory requires approval_mode=ask" in problems
assert "sensitive memory must expire or decay" in problems
creds = {**base, "topic": "credentials"}
assert "topic credentials may not be written to memory" in policy.memory_policy_violations(creds)
assert policy.memory_policy_violations({**creds, "status": "rejected"}) == []
assert "ttl.policy=expires_at requires expires_at" in policy.memory_policy_violations({**base, "ttl": {"policy": "expires_at"}})
assert "ttl.policy=decay requires decay_days" in policy.memory_policy_violations({**base, "ttl": {"policy": "decay"}})
assert "forgotten entries must drop their content" in policy.memory_policy_violations({**base, "status": "forgotten"})
assert policy.memory_policy_violations({**base, "status": "forgotten", "content": ""}) == []
# --- autonomy -------------------------------------------------------------------
def test_capability_matrix_shape():
matrix = policy.default_capability_matrix()
assert set(matrix) == {"ask_first", "safe", "autonomous"}
for level, row in matrix.items():
assert set(row) == set(policy.CAPABILITIES)
assert row["deploy"] == "ask", level
assert row["read_files"] == "allow"
assert matrix["ask_first"]["shell"] == "ask"
assert matrix["safe"]["network"] == "deny"
assert matrix["autonomous"]["shell"] == "allow"
assert matrix["autonomous"]["network"] == "allow"
assert set(policy.CAPABILITIES) == set(SCHEMAS["permission.schema.json"]["$defs"]["capability"]["enum"])
def test_effective_decision_honours_grants_expiry_and_deny():
base = copy.deepcopy(EXAMPLES["policy"])
assert policy.effective_decision(base, "network", NOW) == "ask"
assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "deny"
base["grants"] = [{"capability": "shell", "decision": "allow"}, {"capability": "shell", "decision": "deny"}]
assert policy.effective_decision(base, "shell", NOW) == "deny"
base["grants"] = [{"capability": "deploy", "decision": "allow"}]
assert policy.effective_decision(base, "deploy", NOW) == "ask"
base["grants"] = [{"capability": "write_files", "decision": "allow"}]
assert policy.effective_decision(base, "write_files") == "allow"
assert policy.effective_decision(base, "read_files") == "allow"
def test_approval_states_are_terminal_after_pending():
for state, nexts in policy.APPROVAL_TRANSITIONS.items():
assert bool(nexts) == (state == "pending")
# --- friendly modes ------------------------------------------------------------
@pytest.mark.parametrize("mode", sorted(policy.MODE_CATALOG))
def test_mode_contract_validates_and_resolves_to_real_switchyard_route(mode):
record = policy.mode_contract(mode)
assert contracts.validate_record(record, SCHEMAS) == []
assert record["switchyard"]["route_id"] in _switchyard_route_ids()
assert "codex" not in record["intent"].lower() and "claude" not in record["intent"].lower()
def test_private_mode_never_leaves_the_cluster():
record = policy.mode_contract("private")
assert record["constraints"] == {
"providers": ["local"], "local_only": True, "effort": {"min": "low", "max": "medium"},
"tools": {"web": "denied", "shell": "denied", "artifacts": "allowed", "delegate": "denied"},
"memory": {"read": False, "write": False}, "citations_required": False, "retention": "ephemeral",
}
with pytest.raises(ValueError):
policy.mode_contract("private", "atlas/manual/claude/opus/high")
assert policy.mode_contract("private", "atlas/manual/local/qwen-14b")["switchyard"]["override_route_id"] == "atlas/manual/local/qwen-14b"
def test_hosted_modes_stay_provider_neutral_and_overridable():
for mode in ("fast", "thoughtful", "research", "create"):
assert set(policy.MODE_CATALOG[mode]["providers"]) == {"codex", "claude"}
override = policy.mode_contract("thoughtful", "atlas/manual/codex/sol/high")
assert override["switchyard"]["override_route_id"] in _switchyard_route_ids()
assert policy.MODE_CATALOG["research"]["citations_required"]
assert policy.MODE_CATALOG["research"]["tools"]["web"] == "required"
def test_effort_bands():
assert policy.effort_within("fast", "low")
assert not policy.effort_within("fast", "xhigh")
assert policy.effort_within("research", "xhigh")
assert not policy.effort_within("research", "medium")
# --- privacy -------------------------------------------------------------------
def test_privacy_policy_validates_and_is_strict_for_restricted_topics():
record = policy.privacy_policy()
assert contracts.validate_record(record, SCHEMAS) == []
topics = {row["topic"]: row for row in record["topics"]}
assert set(topics) == set(SCHEMAS["privacy.schema.json"]["$defs"]["topic"]["enum"])
for row in topics.values():
assert (row["sensitivity"] == "restricted") == (row["memory_write"] == "deny")
assert row["decay_days"] <= 30
assert record["topic_scoping"]["cross_surface_sharing"] == "never"
# --- suggestions ---------------------------------------------------------------
def test_suggestion_gating():
suggestion = EXAMPLES["suggestion"]
assert policy.suggestion_allowed(suggestion, None, NOW)
state = copy.deepcopy(EXAMPLES["suggestion_state"])
assert policy.suggestion_allowed(suggestion, state, NOW)
assert not policy.suggestion_allowed(suggestion, {**state, "never_again": True}, NOW)
assert not policy.suggestion_allowed(suggestion, {**state, "dismissed_at": "2026-08-22T11:00:00Z"}, NOW)
assert not policy.suggestion_allowed(suggestion, {**state, "shows": 2}, NOW)
assert not policy.suggestion_allowed(suggestion, {**state, "last_shown_at": "2026-08-23T11:30:00Z"}, NOW)
assert policy.suggestion_allowed(suggestion, {**state, "last_shown_at": None}, NOW)
# --- release follow-through ----------------------------------------------------
def test_release_cannot_skip_states_or_claim_deployed_without_evidence():
record = copy.deepcopy(EXAMPLES["release"])
assert policy.release_transition_problems(record, "verified") == ["verified requires evidence.harbor_digest", "harbor_digest must equal image_digest"]
assert "built -> deployed skips or reverses the release order" in policy.release_transition_problems(record, "deployed")
record["evidence"]["harbor_digest"] = record["evidence"]["image_digest"]
assert policy.release_transition_problems(record, "verified") == []
record["state"] = "deployed"
record["evidence"]["pod_digest"] = "sha256:" + "0" * 64
assert "pod_digest must equal image_digest" in policy.release_transition_problems(record, "converged")
record["evidence"]["pod_digest"] = record["evidence"]["image_digest"]
assert policy.release_transition_problems(record, "converged") == []
record["state"] = "converged"
record["evidence"]["health_check"] = {"url": "https://chat.bstein.dev/healthz", "status": "fail", "at": "2026-08-23T10:00:00Z"}
assert policy.release_transition_problems(record, "live_verified") == ["health_check must pass"]
record["evidence"]["health_check"]["status"] = "pass"
assert policy.release_transition_problems(record, "live_verified") == []
assert policy.release_transition_problems(record, "rolled_back") == ["rolled_back requires evidence.rollback_target"]
record["state"] = "rolled_back"
assert policy.release_transition_problems(record, "merged")[0].startswith("a rolled back release is terminal")
assert policy.release_transition_problems({"state": "merged", "evidence": {}}, "rolled_back")[0].startswith("nothing to roll back")
assert policy.release_transition_problems({"state": "reviewed", "evidence": {}}, "merged") == ["merged requires evidence.merge_commit"]
# --- flags ---------------------------------------------------------------------
def test_flag_registry_matches_program():
registry = policy.flag_registry()
assert sorted(registry) == [f"HUX-{n:02d}" for n in range(1, 13)]
waves = {"A": {"HUX-01", "HUX-02", "HUX-05", "HUX-10", "HUX-11", "HUX-12"}, "B": {"HUX-03", "HUX-04", "HUX-06", "HUX-08"}, "C": {"HUX-07", "HUX-09"}}
for wave, cards in waves.items():
assert {card for card, entry in registry.items() if entry["wave"] == wave} == cards
for entry in registry.values():
assert entry["default"] is False
assert entry["backend_owner"] == "claude" and entry["frontend_owner"] == "codex"
assert entry["rollback"]
for schema in entry["contracts"]:
assert schema in contracts.SCHEMA_FILES
for dep in entry["depends_on"]:
assert dep in registry
order = policy.dependency_order()
assert order.index("HUX-11") < order.index("HUX-01") < order.index("HUX-05")
def test_dependency_cycle_is_rejected(monkeypatch):
registry = policy.flag_registry()
registry["HUX-11"]["depends_on"] = ["HUX-01"]
monkeypatch.setattr(policy, "flag_registry", lambda: registry)
with pytest.raises(ValueError):
policy.dependency_order()
def test_flags_default_off_and_require_dependencies():
assert policy.enabled_flags({}) == set()
assert policy.enabled_flags({"HUX_FLAGS": "hux.activity_timeline, hux.bogus"}) == {"hux.activity_timeline"}
assert not policy.flag_enabled("hux.activity_timeline", {"HUX_FLAGS": "hux.activity_timeline"})
assert policy.flag_enabled("hux.activity_timeline", {"HUX_FLAGS": "hux.activity_timeline,hux.foundation"})
assert not policy.flag_enabled("hux.bogus", {"HUX_FLAGS": "hux.bogus"})
assert not policy.flag_enabled("hux.foundation", {"HUX_FLAGS": ""})
def test_flags_read_process_environment(monkeypatch):
monkeypatch.setenv("HUX_FLAGS", "hux.foundation")
assert policy.flag_enabled("hux.foundation")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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