feat(hermes): add private voice and isolated workflows
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 188
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 188
This commit is contained in:
parent
706073eef5
commit
1b2053511b
37
dockerfiles/Dockerfile.hermes-jetson-stt
Normal file
37
dockerfiles/Dockerfile.hermes-jetson-stt
Normal file
@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# dockerfiles/Dockerfile.hermes-jetson-stt
|
||||
FROM dustynv/whisper@sha256:725c23a68ace3ee667b6465468c38d90e6a0cda9891923a7dd98b851d557569e
|
||||
|
||||
USER root
|
||||
|
||||
# The JetPack 5 base supplies the CUDA-enabled PyTorch build. Keep Whisper new
|
||||
# enough for the multilingual turbo model while preserving that Jetson stack.
|
||||
RUN python3 -m pip install --no-cache-dir --force-reinstall --no-deps \
|
||||
openai-whisper==20250625
|
||||
|
||||
# Keep model acquisition in the audited image build. Runtime pods never need
|
||||
# public egress and Whisper verifies the same digest when loading this cache.
|
||||
ADD --checksum=sha256:aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a --chmod=0444 \
|
||||
https://openaipublic.azureedge.net/main/whisper/models/aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c9839262a19a/large-v3-turbo.pt \
|
||||
/opt/models/whisper/large-v3-turbo.pt
|
||||
|
||||
COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py
|
||||
RUN chmod 0555 /opt/atlas/hermes-jetson-stt-server.py
|
||||
|
||||
# The vendor image defaults to /opt/whisper, whose 2023 source checkout would
|
||||
# shadow the pinned Python package. Run Atlas code from its own directory.
|
||||
WORKDIR /opt/atlas
|
||||
|
||||
# Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact.
|
||||
# Full GPU warm-up is covered by the Kubernetes startup probe on titan-21.
|
||||
RUN python3 -c "from pathlib import Path; import torch, whisper; print(whisper.__file__, whisper.__version__, whisper.available_models()); assert 'large-v3-turbo' in whisper.available_models(); assert Path('/opt/models/whisper/large-v3-turbo.pt').is_file(); print(torch.__version__)"
|
||||
|
||||
ENV HERMES_STT_HOST=0.0.0.0 \
|
||||
HERMES_STT_PORT=9000 \
|
||||
HERMES_STT_MODEL=large-v3-turbo \
|
||||
HERMES_STT_CACHE=/opt/models/whisper \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 9000
|
||||
ENTRYPOINT ["python3", "/opt/atlas/hermes-jetson-stt-server.py"]
|
||||
32
dockerfiles/Dockerfile.hermes-jetson-tts
Normal file
32
dockerfiles/Dockerfile.hermes-jetson-tts
Normal file
@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# dockerfiles/Dockerfile.hermes-jetson-tts
|
||||
FROM python:3.11-slim-bookworm@sha256:d29f48a31a8b408ed19272ca1e7b10ebae13b240a27e862d3d4217c528e2e0c3
|
||||
|
||||
RUN python -m pip install --no-cache-dir piper-tts==1.5.0
|
||||
|
||||
# Pin the voice data in the image so synthesis has no runtime dependency on
|
||||
# Hugging Face or mutable model metadata.
|
||||
ADD --checksum=sha256:4cabf7c3a638017137f34a1516522032d4fe3f38228a843cc9b764ddcbcd9e09 --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/high/en_US-lessac-high.onnx?download=true \
|
||||
/opt/models/piper/en_US-lessac-high.onnx
|
||||
ADD --checksum=sha256:db42b97d9859f257bc1561b8ed980e7fb2398402050a74ddd6cbec931a92412f --chmod=0444 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/high/en_US-lessac-high.onnx.json?download=true \
|
||||
/opt/models/piper/en_US-lessac-high.onnx.json
|
||||
|
||||
COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py
|
||||
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py
|
||||
|
||||
# Load the actual pinned voice during the ARM64 build. This catches package or
|
||||
# model-format drift before the image can reach Flux.
|
||||
RUN python -c "from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); v=PiperVoice.load(p/'en_US-lessac-high.onnx', p/'en_US-lessac-high.onnx.json', use_cuda=False, download_dir=p); assert v.config.sample_rate > 0"
|
||||
|
||||
ENV HERMES_TTS_HOST=0.0.0.0 \
|
||||
HERMES_TTS_PORT=9001 \
|
||||
HERMES_TTS_VOICE=en_US-lessac-high \
|
||||
HERMES_TTS_CACHE=/opt/models/piper \
|
||||
OMP_NUM_THREADS=2 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 9001
|
||||
ENTRYPOINT ["python", "/opt/atlas/hermes-jetson-tts-server.py"]
|
||||
@ -2,7 +2,7 @@
|
||||
# dockerfiles/Dockerfile.hermes-webui
|
||||
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
|
||||
|
||||
FROM registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
|
||||
FROM registry.bstein.dev/bstein/hermes-agent@sha256:a09d36b7467d5810bd814b05d72004629695850d188a2c2a6041af8e2539ba08
|
||||
|
||||
USER root
|
||||
|
||||
@ -29,12 +29,71 @@ before = ' <div class="reasoning-option" data-effort="max">Max</div>\
|
||||
if before not in source:
|
||||
raise SystemExit("Hermes WebUI xhigh UI patch context changed")
|
||||
index.write_text(source.replace(before, "", 1), encoding="utf-8")
|
||||
|
||||
# oauth2-proxy returns 401 for browser API and health probes when the secure
|
||||
# session expires. Re-enter OIDC with the complete return path instead of
|
||||
# presenting an endless, inaccurate "connection lost" loop.
|
||||
ui = Path("/opt/hermes-webui/static/ui.js")
|
||||
source = ui.read_text(encoding="utf-8")
|
||||
before = ''' const res=await fetcher(_offlineHealthUrl(),opts);
|
||||
return !!(res&&res.ok);
|
||||
'''
|
||||
after = ''' const res=await fetcher(_offlineHealthUrl(),opts);
|
||||
if(res&&(res.status===401||res.status===403)){
|
||||
const rd=window.location.pathname+window.location.search+window.location.hash;
|
||||
window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
|
||||
return false;
|
||||
}
|
||||
return !!(res&&res.ok);
|
||||
'''
|
||||
if source.count(before) != 1:
|
||||
raise SystemExit("Hermes WebUI auth-recovery patch context changed")
|
||||
ui.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||
|
||||
# Make delegated session hierarchy obvious and collapsible in the sidebar.
|
||||
sessions = Path("/opt/hermes-webui/static/sessions.js")
|
||||
source = sessions.read_text(encoding="utf-8")
|
||||
before = ''' const childLabel=t('session_meta_children', childCount);
|
||||
childCountEl.textContent=childLabel;
|
||||
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
||||
'''
|
||||
after = ''' const childLabel=t('session_meta_children', childCount);
|
||||
const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
|
||||
childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
|
||||
childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
|
||||
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
||||
'''
|
||||
if source.count(before) != 1:
|
||||
raise SystemExit("Hermes WebUI child-session toggle patch context changed")
|
||||
sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||
|
||||
# A profile's model is only its default; a session-level selector can override
|
||||
# it. Label the scope so the dropdown does not contradict the effective model.
|
||||
panels = Path("/opt/hermes-webui/static/panels.js")
|
||||
source = panels.read_text(encoding="utf-8")
|
||||
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
|
||||
after = " if (typeof p.model === 'string' && p.model) meta.push('profile default: ' + p.model.split('/').pop());\n"
|
||||
if source.count(before) != 2:
|
||||
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
||||
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
|
||||
PY
|
||||
|
||||
# Add the Atlas voice bridge as a narrow integration layer. It activates only
|
||||
# when a tenant's server-side STT capability reports the private Jetson route.
|
||||
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
|
||||
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
|
||||
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
|
||||
|
||||
RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
||||
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
|
||||
/opt/hermes-webui/api/config.py \
|
||||
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html
|
||||
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
||||
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
|
||||
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
||||
&& grep -Fq "profile default: ' + p.model" /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 "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js
|
||||
|
||||
# Exercise the real server process in the target architecture before publish.
|
||||
RUN set -eu; \
|
||||
|
||||
136
dockerfiles/hermes-jetson-stt-server.py
Normal file
136
dockerfiles/hermes-jetson-stt-server.py
Normal file
@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small OpenAI-compatible Whisper service for the dedicated Jetson."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cgi
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import whisper
|
||||
|
||||
|
||||
HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("HERMES_STT_PORT", "9000"))
|
||||
MODEL_NAME = os.getenv("HERMES_STT_MODEL", "large-v3-turbo")
|
||||
CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
|
||||
MAX_AUDIO_BYTES = 30 * 1024 * 1024
|
||||
MODEL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
class SpeechHandler(BaseHTTPRequestHandler):
|
||||
"""Serve health and transcription without exposing a general runtime."""
|
||||
|
||||
server_version = "AtlasWhisper/1"
|
||||
|
||||
def log_message(self, message: str, *args: object) -> None:
|
||||
print(f"[stt] {self.address_string()} {message % args}", flush=True)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path != "/health":
|
||||
_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
_json(
|
||||
self,
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"model": MODEL_NAME,
|
||||
"device": "cuda" if torch.cuda.is_available() else "cpu",
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/audio/transcriptions":
|
||||
_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if content_length <= 0 or content_length > MAX_AUDIO_BYTES:
|
||||
_json(self, 413, {"error": "audio payload is missing or too large"})
|
||||
return
|
||||
|
||||
content_type = self.headers.get("Content-Type", "")
|
||||
if not content_type.lower().startswith("multipart/form-data"):
|
||||
_json(self, 400, {"error": "multipart/form-data is required"})
|
||||
return
|
||||
|
||||
form = cgi.FieldStorage(
|
||||
fp=self.rfile,
|
||||
headers=self.headers,
|
||||
environ={
|
||||
"REQUEST_METHOD": "POST",
|
||||
"CONTENT_TYPE": content_type,
|
||||
"CONTENT_LENGTH": str(content_length),
|
||||
},
|
||||
)
|
||||
audio = form["file"] if "file" in form else None
|
||||
if audio is None or not getattr(audio, "file", None):
|
||||
_json(self, 400, {"error": "file is required"})
|
||||
return
|
||||
|
||||
suffix = Path(getattr(audio, "filename", "audio.wav") or "audio.wav").suffix
|
||||
suffix = suffix if suffix in {".wav", ".webm", ".ogg", ".mp3", ".m4a"} else ".wav"
|
||||
language = str(form.getfirst("language", "auto") or "auto").strip().lower()
|
||||
temp_path = ""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix="atlas-stt-", suffix=suffix, delete=False) as temp:
|
||||
temp_path = temp.name
|
||||
while True:
|
||||
chunk = audio.file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
temp.write(chunk)
|
||||
|
||||
with MODEL_LOCK:
|
||||
result = self.server.model.transcribe( # type: ignore[attr-defined]
|
||||
temp_path,
|
||||
language=None if language in {"", "auto"} else language,
|
||||
task="transcribe",
|
||||
fp16=torch.cuda.is_available(),
|
||||
condition_on_previous_text=False,
|
||||
temperature=0,
|
||||
verbose=False,
|
||||
)
|
||||
transcript = str(result.get("text") or "").strip()
|
||||
_json(self, 200, {"text": transcript, "model": MODEL_NAME})
|
||||
except Exception as exc:
|
||||
print(f"[stt] transcription failed: {exc}", flush=True)
|
||||
_json(self, 500, {"error": "transcription failed"})
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Warm Whisper once, then serve concurrent clients through one GPU lock."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA is required for the Atlas Whisper service")
|
||||
print(f"[stt] loading Whisper {MODEL_NAME} into CUDA", flush=True)
|
||||
model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR))
|
||||
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
|
||||
server.model = model # type: ignore[attr-defined]
|
||||
print(f"[stt] ready on {HOST}:{PORT}", flush=True)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
108
dockerfiles/hermes-jetson-tts-server.py
Normal file
108
dockerfiles/hermes-jetson-tts-server.py
Normal file
@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CPU-only Piper service shared with the routing Jetson."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import wave
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from piper import PiperVoice, SynthesisConfig
|
||||
|
||||
|
||||
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
|
||||
VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high")
|
||||
CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper"))
|
||||
MAX_TEXT_CHARS = 5000
|
||||
VOICE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
class SpeechHandler(BaseHTTPRequestHandler):
|
||||
"""Serve health and bounded local speech synthesis."""
|
||||
|
||||
server_version = "AtlasPiper/1"
|
||||
|
||||
def log_message(self, message: str, *args: object) -> None:
|
||||
print(f"[tts] {self.address_string()} {message % args}", flush=True)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path != "/health":
|
||||
_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
_json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/audio/speech":
|
||||
_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if content_length <= 0 or content_length > 64 * 1024:
|
||||
_json(self, 413, {"error": "request is missing or too large"})
|
||||
return
|
||||
try:
|
||||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||||
text = str(payload.get("input") or payload.get("text") or "").strip()
|
||||
speed = float(payload.get("speed") or 1.0)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
_json(self, 400, {"error": "invalid JSON request"})
|
||||
return
|
||||
if not text:
|
||||
_json(self, 400, {"error": "input is required"})
|
||||
return
|
||||
if len(text) > MAX_TEXT_CHARS:
|
||||
_json(self, 400, {"error": "input is too long"})
|
||||
return
|
||||
speed = min(2.0, max(0.5, speed))
|
||||
|
||||
output = io.BytesIO()
|
||||
try:
|
||||
with VOICE_LOCK, wave.open(output, "wb") as wav_file:
|
||||
self.server.voice.synthesize_wav( # type: ignore[attr-defined]
|
||||
text,
|
||||
wav_file,
|
||||
SynthesisConfig(length_scale=1.0 / speed),
|
||||
)
|
||||
audio = output.getvalue()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "audio/wav")
|
||||
self.send_header("Content-Length", str(len(audio)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(audio)
|
||||
except Exception as exc:
|
||||
print(f"[tts] synthesis failed: {exc}", flush=True)
|
||||
_json(self, 500, {"error": "speech synthesis failed"})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load the checksum-pinned voice from the image and serve it on CPU."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
model_path = CACHE_DIR / f"{VOICE_NAME}.onnx"
|
||||
config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json"
|
||||
if not model_path.exists() or not config_path.exists():
|
||||
raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}")
|
||||
print(f"[tts] loading Piper voice {VOICE_NAME} on CPU", flush=True)
|
||||
voice = PiperVoice.load(model_path, config_path, use_cuda=False, download_dir=CACHE_DIR)
|
||||
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
|
||||
server.voice = voice # type: ignore[attr-defined]
|
||||
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -50,12 +50,13 @@ registry.register(
|
||||
schema={
|
||||
"name": "python_sandbox",
|
||||
"description": (
|
||||
"Run Python in this user's separate credential-free computation "
|
||||
"sandbox. Use it for statistics, probability, Monte Carlo simulation, "
|
||||
"data transforms, and calculations. The sandbox has no Kubernetes, "
|
||||
"Vault, model-provider credentials, or access to other users. Public "
|
||||
"research belongs in web_search; pass only the data needed for the "
|
||||
"calculation and print the result."
|
||||
"Run Python in this user's credential-free computation sandbox. "
|
||||
"Its /workspace and /opt/data/workspace paths expose the same private "
|
||||
"workspace, and /tmp is writable for temporary verifiers. "
|
||||
"Use it for file verification, statistics, probability, Monte Carlo "
|
||||
"simulation, data transforms, and calculations. The sandbox has no "
|
||||
"Kubernetes, Vault, model-provider credentials, or access to other "
|
||||
"users. Public research belongs in web_search."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
||||
96
dockerfiles/hermes-webui-atlas-patch.py
Normal file
96
dockerfiles/hermes-webui-atlas-patch.py
Normal file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path("/opt/hermes-webui")
|
||||
|
||||
|
||||
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
|
||||
"""Replace an exact upstream fragment and fail when the pin has drifted."""
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if source.count(before) != count:
|
||||
raise SystemExit(f"Atlas voice patch context changed in {path}: {before[:80]!r}")
|
||||
path.write_text(source.replace(before, after, count), encoding="utf-8")
|
||||
|
||||
|
||||
index = ROOT / "static/index.html"
|
||||
replace_exact(
|
||||
index,
|
||||
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
|
||||
'<option value="atlas">Atlas Jetson (private)</option><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
|
||||
)
|
||||
replace_exact(
|
||||
index,
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>',
|
||||
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n<script src="static/atlas-voice.js?v=__WEBUI_VERSION__" defer></script>',
|
||||
)
|
||||
|
||||
ui = ROOT / "static/ui.js"
|
||||
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
|
||||
replace_exact(
|
||||
ui,
|
||||
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
|
||||
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
|
||||
)
|
||||
replace_exact(
|
||||
ui,
|
||||
"if(engine==='edge'){\n _playEdgeTtsChunked(clean, btn);",
|
||||
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, btn, engine);",
|
||||
)
|
||||
replace_exact(
|
||||
ui,
|
||||
"if(engine==='edge'){\n _playEdgeTtsChunked(clean, null);",
|
||||
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
|
||||
)
|
||||
|
||||
routes = ROOT / "api/routes.py"
|
||||
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
|
||||
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
|
||||
if engine == "atlas":
|
||||
atlas_url = os.getenv("HERMES_WEBUI_ATLAS_TTS_URL", "").strip()
|
||||
expected_url = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
|
||||
if atlas_url != expected_url:
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "Atlas private TTS is not configured", 503)
|
||||
speed = 1.0
|
||||
if rate_str:
|
||||
try:
|
||||
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
|
||||
except ValueError:
|
||||
speed = 1.0
|
||||
request_body = json.dumps({
|
||||
"model": "piper",
|
||||
"input": text,
|
||||
"voice": "en_US-lessac-high",
|
||||
"speed": speed,
|
||||
}).encode("utf-8")
|
||||
request = Request(atlas_url, data=request_body, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "audio/wav",
|
||||
})
|
||||
try:
|
||||
with _tts_open(
|
||||
request,
|
||||
timeout=45,
|
||||
opener_factory=lambda: build_opener(ProxyHandler({}), _NoRedirectTtsHandler()),
|
||||
) as response:
|
||||
audio_data = _buffer_tts_audio_response(response)
|
||||
except Exception:
|
||||
logger.exception("Atlas private TTS generation failed")
|
||||
from api.helpers import bad as _bad
|
||||
return _bad(handler, "Atlas private TTS generation failed", 502)
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "audio/wav")
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.send_header("Content-Length", str(len(audio_data)))
|
||||
handler.end_headers()
|
||||
try:
|
||||
handler.wfile.write(audio_data)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return True
|
||||
|
||||
'''
|
||||
replace_exact(routes, marker, atlas + marker)
|
||||
287
dockerfiles/hermes-webui-atlas-voice.js
Normal file
287
dockerfiles/hermes-webui-atlas-voice.js
Normal file
@ -0,0 +1,287 @@
|
||||
// Natural turn-taking for chat.hermes.bstein.dev using the private Jetsons.
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
const modeBtn=document.getElementById('btnVoiceMode');
|
||||
const bar=document.getElementById('voiceModeBar');
|
||||
const indicator=document.getElementById('voiceModeIndicator');
|
||||
const label=document.getElementById('voiceModeLabel');
|
||||
const composer=document.getElementById('msg');
|
||||
if(!modeBtn||!bar||!indicator||!label||!composer||!navigator.mediaDevices||!window.MediaRecorder) return;
|
||||
|
||||
let ready=false;
|
||||
let active=false;
|
||||
let state='idle';
|
||||
let generation=0;
|
||||
let recorder=null;
|
||||
let stream=null;
|
||||
let audioContext=null;
|
||||
let vadTimer=null;
|
||||
let currentAudio=null;
|
||||
let thinkingSession=null;
|
||||
const originalAutoRead=window.autoReadLastAssistant;
|
||||
const originalApplyPreference=window._applyVoiceModePref;
|
||||
|
||||
function toast(message){
|
||||
if(typeof window.showToast==='function') window.showToast(message,3000);
|
||||
}
|
||||
|
||||
function setState(next, customLabel){
|
||||
state=next;
|
||||
indicator.className='voice-mode-indicator '+next;
|
||||
label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':'');
|
||||
bar.style.display=active&&next!=='idle'?'':'none';
|
||||
}
|
||||
|
||||
function stopCapture(){
|
||||
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
|
||||
if(recorder&&recorder.state!=='inactive'){
|
||||
try{recorder.stop();}catch(_){ }
|
||||
}
|
||||
recorder=null;
|
||||
if(stream){stream.getTracks().forEach(function(track){track.stop();});stream=null;}
|
||||
if(audioContext){try{audioContext.close();}catch(_){ }audioContext=null;}
|
||||
}
|
||||
|
||||
function stopPlayback(){
|
||||
if(!currentAudio) return;
|
||||
try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ }
|
||||
currentAudio=null;
|
||||
}
|
||||
|
||||
function deactivate(showMessage){
|
||||
generation+=1;
|
||||
active=false;
|
||||
state='idle';
|
||||
thinkingSession=null;
|
||||
stopCapture();
|
||||
stopPlayback();
|
||||
modeBtn.classList.remove('active');
|
||||
bar.style.display='none';
|
||||
if(showMessage) toast('Hands-free voice mode off');
|
||||
}
|
||||
|
||||
function restartSoon(token, delay){
|
||||
window.setTimeout(function(){
|
||||
if(active&&token===generation) startListening(token);
|
||||
},delay||500);
|
||||
}
|
||||
|
||||
function sendTranscript(transcript, token){
|
||||
if(!active||token!==generation) return;
|
||||
const text=String(transcript||'').trim();
|
||||
if(!text){restartSoon(token,350);return;}
|
||||
composer.value=text;
|
||||
if(typeof window.autoResize==='function') window.autoResize();
|
||||
thinkingSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||
setState('thinking');
|
||||
if(typeof window.send==='function') window.send();
|
||||
}
|
||||
|
||||
async function transcribe(blob, token){
|
||||
if(!active||token!==generation) return;
|
||||
setState('thinking','Transcribing…');
|
||||
const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm';
|
||||
const form=new FormData();
|
||||
form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext}));
|
||||
try{
|
||||
const response=await fetch('/api/transcribe',{method:'POST',body:form});
|
||||
const payload=await response.json().catch(function(){return {};});
|
||||
if(!response.ok) throw new Error(payload.error||('Whisper request failed: '+response.status));
|
||||
sendTranscript(payload.transcript,token);
|
||||
}catch(error){
|
||||
if(!active||token!==generation) return;
|
||||
deactivate(false);
|
||||
toast((error&&error.message)||'Private Whisper is unavailable');
|
||||
// If the browser supplies its own recognizer, hand control back to the
|
||||
// upstream voice implementation until the Jetson becomes healthy again.
|
||||
if(window.SpeechRecognition||window.webkitSpeechRecognition){
|
||||
modeBtn.removeEventListener('click',onVoiceClick,true);
|
||||
window.setTimeout(function(){modeBtn.click();},50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startListening(token){
|
||||
if(!active||token!==generation) return;
|
||||
stopCapture();
|
||||
setState('listening');
|
||||
try{
|
||||
const capture=await navigator.mediaDevices.getUserMedia({
|
||||
audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true},
|
||||
});
|
||||
if(!active||token!==generation){capture.getTracks().forEach(function(track){track.stop();});return;}
|
||||
stream=capture;
|
||||
const Context=window.AudioContext||window.webkitAudioContext;
|
||||
audioContext=new Context();
|
||||
const analyser=audioContext.createAnalyser();
|
||||
analyser.fftSize=1024;
|
||||
audioContext.createMediaStreamSource(stream).connect(analyser);
|
||||
const samples=new Uint8Array(analyser.fftSize);
|
||||
const mimeTypes=['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm'];
|
||||
const mime=mimeTypes.find(function(value){return MediaRecorder.isTypeSupported(value);})||'';
|
||||
const chunks=[];
|
||||
let heardSpeech=false;
|
||||
let lastSpeech=Date.now();
|
||||
const started=Date.now();
|
||||
recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined);
|
||||
recorder.ondataavailable=function(event){if(event.data&&event.data.size) chunks.push(event.data);};
|
||||
recorder.onstop=function(){
|
||||
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
|
||||
const recordedStream=stream;
|
||||
stream=null;
|
||||
if(recordedStream) recordedStream.getTracks().forEach(function(track){track.stop();});
|
||||
if(audioContext){try{audioContext.close();}catch(_){ }audioContext=null;}
|
||||
recorder=null;
|
||||
if(!active||token!==generation) return;
|
||||
if(!heardSpeech||!chunks.length){restartSoon(token,300);return;}
|
||||
transcribe(new Blob(chunks,{type:mime||'audio/webm'}),token);
|
||||
};
|
||||
recorder.start(250);
|
||||
const silenceMs=Math.max(900,parseInt(localStorage.getItem('hermes-voice-silence-ms')||'1600',10)||1600);
|
||||
vadTimer=window.setInterval(function(){
|
||||
if(!active||token!==generation||!recorder||recorder.state==='inactive') return;
|
||||
analyser.getByteTimeDomainData(samples);
|
||||
let energy=0;
|
||||
for(let i=0;i<samples.length;i++){
|
||||
const normalized=(samples[i]-128)/128;
|
||||
energy+=normalized*normalized;
|
||||
}
|
||||
const rms=Math.sqrt(energy/samples.length);
|
||||
const now=Date.now();
|
||||
if(rms>0.025){heardSpeech=true;lastSpeech=now;}
|
||||
const finished=heardSpeech&&(now-lastSpeech)>=silenceMs;
|
||||
const timedOut=now-started>=90000;
|
||||
const idle=(!heardSpeech)&&(now-started)>=20000;
|
||||
if(finished||timedOut||idle){
|
||||
if(vadTimer){clearInterval(vadTimer);vadTimer=null;}
|
||||
try{recorder.stop();}catch(_){ }
|
||||
}
|
||||
},100);
|
||||
}catch(error){
|
||||
if(!active||token!==generation) return;
|
||||
deactivate(false);
|
||||
toast((error&&error.message)||'Microphone permission is required');
|
||||
}
|
||||
}
|
||||
|
||||
function cleanForSpeech(text){
|
||||
if(typeof window._stripForTTS==='function') return window._stripForTTS(text);
|
||||
return String(text||'').replace(/```[\s\S]*?```/g,' code block ').replace(/\s+/g,' ').trim();
|
||||
}
|
||||
|
||||
function playBlob(blob, token){
|
||||
return new Promise(function(resolve,reject){
|
||||
if(!active||token!==generation){resolve();return;}
|
||||
const url=URL.createObjectURL(blob);
|
||||
const audio=new Audio(url);
|
||||
currentAudio=audio;
|
||||
function cleanup(){
|
||||
if(currentAudio===audio) currentAudio=null;
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
audio.onended=function(){cleanup();resolve();};
|
||||
audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));};
|
||||
audio.play().catch(function(error){cleanup();reject(error);});
|
||||
});
|
||||
}
|
||||
|
||||
async function speakResponse(token){
|
||||
if(!active||token!==generation) return;
|
||||
const currentSession=(typeof S!=='undefined'&&S.session)?S.session.session_id:null;
|
||||
if(thinkingSession&¤tSession&&thinkingSession!==currentSession){
|
||||
thinkingSession=null;
|
||||
restartSoon(token,250);
|
||||
return;
|
||||
}
|
||||
thinkingSession=null;
|
||||
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
|
||||
if(!rows.length){restartSoon(token,250);return;}
|
||||
const text=cleanForSpeech(rows[rows.length-1].dataset.rawText||'');
|
||||
if(!text){restartSoon(token,250);return;}
|
||||
setState('speaking');
|
||||
const chunks=typeof window._splitForTTS==='function'?window._splitForTTS(text,900):[text];
|
||||
try{
|
||||
for(const chunk of chunks){
|
||||
if(!active||token!==generation) return;
|
||||
const response=await fetch('/api/tts',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({text:chunk,engine:'atlas'}),
|
||||
});
|
||||
if(!response.ok){
|
||||
const payload=await response.json().catch(function(){return {};});
|
||||
throw new Error(payload.error||('Local speech request failed: '+response.status));
|
||||
}
|
||||
await playBlob(await response.blob(),token);
|
||||
}
|
||||
}catch(error){
|
||||
if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable');
|
||||
}
|
||||
restartSoon(token,450);
|
||||
}
|
||||
|
||||
function activate(){
|
||||
generation+=1;
|
||||
const token=generation;
|
||||
active=true;
|
||||
modeBtn.classList.add('active');
|
||||
toast('Hands-free private voice mode on');
|
||||
if(typeof window.stopTTS==='function') window.stopTTS();
|
||||
if(typeof S!=='undefined'&&S.busy){setState('thinking');return;}
|
||||
startListening(token);
|
||||
}
|
||||
|
||||
function onVoiceClick(event){
|
||||
if(!ready) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if(active) deactivate(true); else activate();
|
||||
}
|
||||
|
||||
async function initialize(){
|
||||
try{
|
||||
const response=await fetch('/api/transcribe/capability',{cache:'no-store'});
|
||||
const capability=await response.json().catch(function(){return {};});
|
||||
if(!response.ok||!capability.available||capability.provider!=='local_command') return;
|
||||
ready=true;
|
||||
if(localStorage.getItem('hermes-atlas-voice-initialized')!=='1'){
|
||||
localStorage.setItem('hermes-atlas-voice-initialized','1');
|
||||
localStorage.setItem('hermes-voice-mode-button','true');
|
||||
localStorage.setItem('hermes-tts-engine','atlas');
|
||||
localStorage.setItem('hermes-tts-enabled','true');
|
||||
localStorage.setItem('hermes-voice-silence-ms','1600');
|
||||
}
|
||||
const selector=document.getElementById('settingsTtsEngine');
|
||||
if(selector&&!selector.querySelector('option[value="atlas"]')){
|
||||
const option=document.createElement('option');
|
||||
option.value='atlas';
|
||||
option.textContent='Atlas Jetson (private)';
|
||||
selector.insertBefore(option,selector.firstChild);
|
||||
}
|
||||
modeBtn.style.display=localStorage.getItem('hermes-voice-mode-button')==='false'?'none':'';
|
||||
modeBtn.addEventListener('click',onVoiceClick,true);
|
||||
window._applyVoiceModePref=function(){
|
||||
if(typeof originalApplyPreference==='function') originalApplyPreference();
|
||||
if(ready){
|
||||
const enabled=localStorage.getItem('hermes-voice-mode-button')!=='false';
|
||||
modeBtn.style.display=enabled?'':'none';
|
||||
if(!enabled&&active) deactivate(false);
|
||||
}
|
||||
};
|
||||
window.autoReadLastAssistant=function(){
|
||||
if(active&&state==='thinking'){speakResponse(generation);return;}
|
||||
if(typeof originalAutoRead==='function') originalAutoRead.apply(this,arguments);
|
||||
};
|
||||
window._voiceModeActive=function(){return active;};
|
||||
window._voiceModeDeactivate=function(){deactivate(false);};
|
||||
window._voiceModeImmediateSend=function(){
|
||||
if(active&&recorder&&recorder.state!=='inactive') recorder.stop();
|
||||
};
|
||||
}catch(_){
|
||||
// The upstream browser voice implementation remains available as fallback.
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
})();
|
||||
@ -21,7 +21,7 @@ spec:
|
||||
app: ollama
|
||||
annotations:
|
||||
ai.bstein.dev/model: qwen2.5:3b-instruct-q4_0,qwen2.5:14b-instruct-q4_0
|
||||
ai.bstein.dev/gpu: GPU pool (titan-20/21)
|
||||
ai.bstein.dev/gpu: titan-20 shared routing GPU
|
||||
ai.bstein.dev/restartedAt: "2026-01-26T12:00:00Z"
|
||||
spec:
|
||||
affinity:
|
||||
@ -33,7 +33,6 @@ spec:
|
||||
operator: In
|
||||
values:
|
||||
- titan-20
|
||||
- titan-21
|
||||
runtimeClassName: nvidia
|
||||
volumes:
|
||||
- name: models
|
||||
|
||||
@ -99,7 +99,10 @@ data:
|
||||
home_mode: auto
|
||||
|
||||
approvals:
|
||||
mode: smart
|
||||
# This owner-only workspace is already bounded by non-root execution,
|
||||
# repository scope, and read-only cluster RBAC. Keep routine engineering
|
||||
# work non-interactive while retaining explicit hard denies below.
|
||||
mode: "off"
|
||||
deny:
|
||||
- "*kubectl apply*"
|
||||
- "*kubectl delete*"
|
||||
@ -111,6 +114,10 @@ data:
|
||||
- "*flux suspend*"
|
||||
- "*flux resume*"
|
||||
- "*vault kv*"
|
||||
- "*git push --force*"
|
||||
- "*git push -f*"
|
||||
- "*git reset --hard*"
|
||||
- "*git clean -f*"
|
||||
|
||||
dashboard:
|
||||
public_url: https://agent.hermes.bstein.dev
|
||||
@ -204,9 +211,15 @@ data:
|
||||
`herdr-dispatch --shape <implementation|architecture|review> --effort <low|medium|high|xhigh> [--provider codex|claude]`
|
||||
|
||||
Add `--start --project <path> --task <short-name> --prompt <objective>` to
|
||||
create a Herdr workspace and launch the selected CLI. Use `herdr agent list`,
|
||||
`herdr agent wait`, `herdr agent read`, and `herdr agent prompt` to supervise
|
||||
it. If Codex reports its first-use login requirement, run
|
||||
create a separate Herdr worker space and launch the selected CLI. The visible
|
||||
project tab remains a single Hermes coordinator pane. Never split Codex,
|
||||
Claude, or a second Hermes process into that tab. Worker-space labels include
|
||||
the parent project, provider, and task (for example
|
||||
`cassandra-claude-review`) so the left rail preserves ownership and purpose.
|
||||
Keep completed workers available as labeled spaces until their evidence has
|
||||
been synthesized; do not tile them over the coordinator. Use `herdr agent
|
||||
list`, `herdr agent wait`, `herdr agent read`, and `herdr agent prompt` to
|
||||
supervise them. If Codex reports its first-use login requirement, run
|
||||
`codex login --device-auth` once and ask Brad to complete the displayed code.
|
||||
|
||||
A hosted capacity failure should fall across providers at the same effort
|
||||
|
||||
@ -24,7 +24,7 @@ spec:
|
||||
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
|
||||
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
|
||||
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
|
||||
ai.bstein.dev/config-rev: "20260809-terminal-recovery-v2"
|
||||
ai.bstein.dev/config-rev: "20260810-worker-spaces-permissions"
|
||||
vault.hashicorp.com/agent-inject: "true"
|
||||
vault.hashicorp.com/role: hermes-agent
|
||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
||||
@ -350,7 +350,7 @@ spec:
|
||||
requests: {cpu: 250m, memory: 512Mi}
|
||||
limits: {cpu: "2", memory: 4Gi}
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:06f195df381abc60e97f31c0676044faffd1a51cb365cda77a919eee04827c71
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -35,6 +35,12 @@ data:
|
||||
backend: ddgs
|
||||
search_backend: ddgs
|
||||
extract_backend: public-extract
|
||||
stt:
|
||||
enabled: true
|
||||
provider: local_command
|
||||
local:
|
||||
model: large-v3-turbo
|
||||
language: auto
|
||||
model_catalog:
|
||||
enabled: true
|
||||
ttl_hours: 1
|
||||
@ -81,16 +87,26 @@ data:
|
||||
user's private workspace and may use this user's private memory, skills,
|
||||
profiles, and task list. Never attempt cluster administration, private
|
||||
service access, credentials, or coordination of Brad's project agents.
|
||||
Python may run only through the credential-free sandbox tool. The user's
|
||||
conversations and files must never be mixed with another Keycloak user's
|
||||
state.
|
||||
Python may run only through the credential-free sandbox tool. Its
|
||||
`/workspace` and `/opt/data/workspace` both expose the same private 10 GiB
|
||||
workspace, and its writable `/tmp` is available for temporary
|
||||
verification scripts. Read and verify generated artifacts directly there;
|
||||
do not claim the sandbox is disconnected from the user's files. The user's
|
||||
conversations and files must never be mixed with another Keycloak user's state.
|
||||
|
||||
Voice conversations use the same assistant, session, AUTO route, tools, and
|
||||
private workspace as typed conversations. Whisper and speech synthesis are
|
||||
transport services only; they do not select or replace the answering model.
|
||||
AGENTS.md: |
|
||||
# Private Hermes chat
|
||||
|
||||
This runtime belongs to one authenticated Keycloak identity and one private
|
||||
persistent volume. Provide conversational help with the private workspace,
|
||||
persistent workspace. Provide conversational help with the private workspace,
|
||||
memory, skills, profiles, task list, session search, public web tools, and
|
||||
the separate per-tenant browser and Python sandbox. Use delegation selectively for
|
||||
independent research or verification, then present a single final answer.
|
||||
the per-tenant browser and Python sandbox. The Python sandbox sees the same
|
||||
workspace at both `/workspace` and `/opt/data/workspace`, and may use `/tmp`
|
||||
for bounded temporary work. Use
|
||||
delegation selectively for independent research or verification, then
|
||||
present a single final answer.
|
||||
Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other
|
||||
users, the agent coordinator, or automated triage.
|
||||
|
||||
@ -12,39 +12,147 @@ spec:
|
||||
selector:
|
||||
app: hermes-chat-sandbox
|
||||
ports:
|
||||
- name: http
|
||||
port: 9080
|
||||
targetPort: http
|
||||
- {name: http, port: 9080, targetPort: http, protocol: TCP}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-chat-sandbox
|
||||
name: hermes-chat-sandbox-0
|
||||
namespace: hermes
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "0"
|
||||
spec:
|
||||
serviceName: hermes-chat-sandbox
|
||||
replicas: 4
|
||||
podManagementPolicy: Parallel
|
||||
persistentVolumeClaimRetentionPolicy:
|
||||
whenDeleted: Retain
|
||||
whenScaled: Retain
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "0"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "0"
|
||||
annotations:
|
||||
ai.bstein.dev/role: isolated-user-computation
|
||||
ai.bstein.dev/isolation: one credential-free sandbox per chat tenant
|
||||
ai.bstein.dev/isolation: credential-free and default-deny network
|
||||
spec:
|
||||
hostname: hermes-chat-sandbox-0
|
||||
subdomain: hermes-chat-sandbox
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 20000
|
||||
fsGroup: 10000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
affinity: &sandbox-affinity
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values: [arm64]
|
||||
- key: node-role.kubernetes.io/worker
|
||||
operator: In
|
||||
values: ["true"]
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi5]
|
||||
- weight: 40
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi4]
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- &sandbox-container
|
||||
name: sandbox
|
||||
image: registry.bstein.dev/bstein/hermes-chat-sandbox@sha256:17ee62b8e61c08573a3a8cca903b38ec43800cb44ec29340e1bc095176544bca
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9080, protocol: TCP}
|
||||
volumeMounts:
|
||||
- {name: workspace, mountPath: /workspace}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 3
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 20000
|
||||
runAsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
volumes:
|
||||
- name: workspace
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-hermes-chat-tenant-0
|
||||
- &sandbox-tmp
|
||||
name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 256Mi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-chat-sandbox-1
|
||||
namespace: hermes
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "1"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "1"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "1"
|
||||
annotations:
|
||||
ai.bstein.dev/role: isolated-user-computation
|
||||
ai.bstein.dev/isolation: credential-free and default-deny network
|
||||
spec:
|
||||
hostname: hermes-chat-sandbox-1
|
||||
subdomain: hermes-chat-sandbox
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 10000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
@ -61,7 +169,7 @@ spec:
|
||||
values: ["true"]
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18]
|
||||
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
@ -91,6 +199,7 @@ spec:
|
||||
- {name: http, containerPort: 9080, protocol: TCP}
|
||||
volumeMounts:
|
||||
- {name: workspace, mountPath: /workspace}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
@ -109,24 +218,232 @@ spec:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 20000
|
||||
runAsGroup: 20000
|
||||
runAsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
volumes:
|
||||
- name: workspace
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-hermes-chat-tenant-1
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 256Mi
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: workspace
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-chat-sandbox-2
|
||||
namespace: hermes
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "2"
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: astreae
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "2"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "2"
|
||||
annotations:
|
||||
ai.bstein.dev/role: isolated-user-computation
|
||||
ai.bstein.dev/isolation: credential-free and default-deny network
|
||||
spec:
|
||||
hostname: hermes-chat-sandbox-2
|
||||
subdomain: hermes-chat-sandbox
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 10000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values: [arm64]
|
||||
- key: node-role.kubernetes.io/worker
|
||||
operator: In
|
||||
values: ["true"]
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi5]
|
||||
- weight: 40
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi4]
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: sandbox
|
||||
image: registry.bstein.dev/bstein/hermes-chat-sandbox@sha256:17ee62b8e61c08573a3a8cca903b38ec43800cb44ec29340e1bc095176544bca
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9080, protocol: TCP}
|
||||
volumeMounts:
|
||||
- {name: workspace, mountPath: /workspace}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 3
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 20000
|
||||
runAsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
volumes:
|
||||
- name: workspace
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-hermes-chat-tenant-2
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 256Mi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-chat-sandbox-3
|
||||
namespace: hermes
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "3"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "3"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-chat-sandbox
|
||||
ai.bstein.dev/tenant-ordinal: "3"
|
||||
annotations:
|
||||
ai.bstein.dev/role: isolated-user-computation
|
||||
ai.bstein.dev/isolation: credential-free and default-deny network
|
||||
spec:
|
||||
hostname: hermes-chat-sandbox-3
|
||||
subdomain: hermes-chat-sandbox
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 10000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values: [arm64]
|
||||
- key: node-role.kubernetes.io/worker
|
||||
operator: In
|
||||
values: ["true"]
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [titan-05, titan-08, titan-13, titan-14, titan-17, titan-18, titan-19]
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi5]
|
||||
- weight: 40
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: hardware
|
||||
operator: In
|
||||
values: [rpi4]
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: sandbox
|
||||
image: registry.bstein.dev/bstein/hermes-chat-sandbox@sha256:17ee62b8e61c08573a3a8cca903b38ec43800cb44ec29340e1bc095176544bca
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9080, protocol: TCP}
|
||||
volumeMounts:
|
||||
- {name: workspace, mountPath: /workspace}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 3
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 20000
|
||||
runAsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
volumes:
|
||||
- name: workspace
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-hermes-chat-tenant-3
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 256Mi
|
||||
|
||||
@ -28,7 +28,7 @@ spec:
|
||||
ai.bstein.dev/role: isolated-user-chat
|
||||
ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject
|
||||
ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides
|
||||
ai.bstein.dev/config-rev: "20260809-browser-runtime-path"
|
||||
ai.bstein.dev/config-rev: "20260810-natural-jetson-voice"
|
||||
vault.hashicorp.com/agent-inject: "true"
|
||||
vault.hashicorp.com/role: hermes-chat
|
||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
||||
@ -100,6 +100,12 @@ spec:
|
||||
- |
|
||||
set -eu
|
||||
mkdir -p /opt/data/home/.local/bin /opt/data/logs /opt/data/workspace
|
||||
if [ ! -e /opt/data/workspace/.hermes-workspace-v1 ]; then
|
||||
if [ -d /legacy-home/workspace ]; then
|
||||
cp -a /legacy-home/workspace/. /opt/data/workspace/
|
||||
fi
|
||||
touch /opt/data/workspace/.hermes-workspace-v1
|
||||
fi
|
||||
cp /config/config.yaml /opt/data/config.yaml
|
||||
cp /config/SOUL.md /opt/data/SOUL.md
|
||||
cp /config/AGENTS.md /opt/data/workspace/AGENTS.md
|
||||
@ -142,6 +148,8 @@ spec:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- {name: home, mountPath: /opt/data}
|
||||
- {name: home, mountPath: /legacy-home, readOnly: true}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: config, mountPath: /config, readOnly: true}
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 32Mi}
|
||||
@ -195,6 +203,7 @@ spec:
|
||||
- {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
|
||||
volumeMounts:
|
||||
- {name: home, mountPath: /opt/data}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: provider-auth, mountPath: /shared-auth, readOnly: true}
|
||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||
readinessProbe:
|
||||
@ -217,7 +226,7 @@ spec:
|
||||
requests: {cpu: 250m, memory: 512Mi}
|
||||
limits: {cpu: "1", memory: 2Gi}
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:06f195df381abc60e97f31c0676044faffd1a51cb365cda77a919eee04827c71
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
@ -246,9 +255,14 @@ spec:
|
||||
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
|
||||
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
|
||||
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
|
||||
- {name: HERMES_STT_URL, value: http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions}
|
||||
- {name: HERMES_LOCAL_STT_COMMAND, value: "/opt/hermes/.venv/bin/python /opt/coordinator/hermes_stt_client.py {input_path} --output-dir {output_dir} --language {language} --model {model}"}
|
||||
- {name: HERMES_WEBUI_ATLAS_TTS_URL, value: http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech}
|
||||
volumeMounts:
|
||||
- {name: home, mountPath: /opt/data}
|
||||
- {name: workspace, mountPath: /opt/data/workspace}
|
||||
- {name: provider-auth, mountPath: /shared-auth, readOnly: true}
|
||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: webui}
|
||||
@ -299,3 +313,14 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
- metadata:
|
||||
name: workspace
|
||||
labels:
|
||||
app: hermes-chat-tenant
|
||||
ai.bstein.dev/data: user-workspace
|
||||
spec:
|
||||
accessModes: [ReadWriteMany]
|
||||
storageClassName: astreae
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
|
||||
@ -345,7 +345,7 @@ spec:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:06f195df381abc60e97f31c0676044faffd1a51cb365cda77a919eee04827c71
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -20,6 +20,7 @@ resources:
|
||||
- ollama-deployment.yaml
|
||||
- deployment.yaml
|
||||
- agent-deployment.yaml
|
||||
- voice-deployment.yaml
|
||||
- chat-statefulset.yaml
|
||||
- chat-sandbox.yaml
|
||||
- chat-router.yaml
|
||||
@ -49,6 +50,7 @@ configMapGenerator:
|
||||
- herdr_tab_router.py=scripts/herdr_tab_router.py
|
||||
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
||||
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
||||
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
||||
- patch_hermes_auth.py=scripts/patch_hermes_auth.py
|
||||
- patch_ttyd_index.py=scripts/patch_ttyd_index.py
|
||||
options:
|
||||
|
||||
@ -164,14 +164,13 @@ spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
apps.kubernetes.io/pod-index: "0"
|
||||
ai.bstein.dev/tenant-ordinal: "0"
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-tenant
|
||||
apps.kubernetes.io/pod-index: "0"
|
||||
statefulset.kubernetes.io/pod-name: hermes-chat-tenant-0
|
||||
ports:
|
||||
- {protocol: TCP, port: 9080}
|
||||
---
|
||||
@ -184,14 +183,13 @@ spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
apps.kubernetes.io/pod-index: "1"
|
||||
ai.bstein.dev/tenant-ordinal: "1"
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-tenant
|
||||
apps.kubernetes.io/pod-index: "1"
|
||||
statefulset.kubernetes.io/pod-name: hermes-chat-tenant-1
|
||||
ports:
|
||||
- {protocol: TCP, port: 9080}
|
||||
---
|
||||
@ -204,14 +202,13 @@ spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
apps.kubernetes.io/pod-index: "2"
|
||||
ai.bstein.dev/tenant-ordinal: "2"
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-tenant
|
||||
apps.kubernetes.io/pod-index: "2"
|
||||
statefulset.kubernetes.io/pod-name: hermes-chat-tenant-2
|
||||
ports:
|
||||
- {protocol: TCP, port: 9080}
|
||||
---
|
||||
@ -224,14 +221,13 @@ spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-sandbox
|
||||
apps.kubernetes.io/pod-index: "3"
|
||||
ai.bstein.dev/tenant-ordinal: "3"
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-tenant
|
||||
apps.kubernetes.io/pod-index: "3"
|
||||
statefulset.kubernetes.io/pod-name: hermes-chat-tenant-3
|
||||
ports:
|
||||
- {protocol: TCP, port: 9080}
|
||||
---
|
||||
@ -294,6 +290,15 @@ spec:
|
||||
app: hermes-chat-sandbox
|
||||
ports:
|
||||
- {protocol: TCP, port: 9080}
|
||||
- to:
|
||||
- podSelector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values: [hermes-stt, hermes-tts]
|
||||
ports:
|
||||
- {protocol: TCP, port: 9000}
|
||||
- {protocol: TCP, port: 9001}
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
@ -426,3 +431,35 @@ spec:
|
||||
- {protocol: TCP, port: 7681}
|
||||
- {protocol: TCP, port: 8787}
|
||||
- {protocol: TCP, port: 8080}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: hermes-private-voice
|
||||
namespace: hermes
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values: [hermes-stt, hermes-tts]
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: hermes-chat-tenant
|
||||
ports:
|
||||
- {protocol: TCP, port: 9000}
|
||||
- {protocol: TCP, port: 9001}
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
|
||||
@ -192,7 +192,9 @@ spec:
|
||||
- --cookie-csrf-per-request=true
|
||||
- --cookie-csrf-per-request-limit=8
|
||||
- --cookie-refresh=1h
|
||||
- --cookie-expire=8h
|
||||
- --cookie-expire=168h
|
||||
- --api-route=^/api/
|
||||
- --api-route=^/health$
|
||||
- --upstream=http://hermes-triage.hermes.svc.cluster.local:8787
|
||||
- --http-address=0.0.0.0:4180
|
||||
- --skip-provider-button=true
|
||||
@ -286,9 +288,11 @@ spec:
|
||||
- --cookie-csrf-per-request=true
|
||||
- --cookie-csrf-per-request-limit=8
|
||||
- --cookie-refresh=1h
|
||||
- --cookie-expire=8h
|
||||
- --cookie-expire=168h
|
||||
- --custom-templates-dir=/etc/oauth2-proxy/templates
|
||||
- '--skip-auth-route=GET=^/sw[.]js([?].*)?$'
|
||||
- --api-route=^/api/
|
||||
- --api-route=^/health$
|
||||
- --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080
|
||||
- --http-address=0.0.0.0:4180
|
||||
- --skip-provider-button=true
|
||||
|
||||
@ -18,7 +18,7 @@ HERDR_BIN = Path("/opt/data/tools/bin/herdr")
|
||||
CODEX_AUTH = Path("/opt/data/home/.codex/auth.json")
|
||||
PROMPT_READY_MARKERS = {
|
||||
"codex": "OpenAI Codex",
|
||||
"claude": "accept edits on",
|
||||
"claude": "Claude Code",
|
||||
}
|
||||
|
||||
|
||||
@ -122,7 +122,9 @@ def launch_worker(
|
||||
"PATH": "/opt/data/tools/bin:" + env.get("PATH", ""),
|
||||
}
|
||||
)
|
||||
label = _slug(task)
|
||||
project_label = _slug(project.name, 12)
|
||||
task_label = _slug(task, 14)
|
||||
label = _slug(f"{project_label}-{plan['worker']}-{task_label}")
|
||||
created = _run(
|
||||
[
|
||||
str(HERDR_BIN),
|
||||
@ -141,7 +143,7 @@ def launch_worker(
|
||||
except (KeyError, TypeError) as error:
|
||||
raise RuntimeError("Herdr workspace response omitted the root pane") from error
|
||||
|
||||
agent_name = _slug(f"{plan['worker']}-{task}")
|
||||
agent_name = label
|
||||
command = [
|
||||
str(HERDR_BIN),
|
||||
"agent",
|
||||
@ -162,10 +164,9 @@ def launch_worker(
|
||||
plan["model"],
|
||||
"-c",
|
||||
f'model_reasoning_effort="{plan["effort"]}"',
|
||||
"-c",
|
||||
'approval_policy="on-request"',
|
||||
"-c",
|
||||
'sandbox_mode="workspace-write"',
|
||||
"--approve-for-me",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
]
|
||||
)
|
||||
else:
|
||||
@ -176,7 +177,7 @@ def launch_worker(
|
||||
"--effort",
|
||||
plan["effort"],
|
||||
"--permission-mode",
|
||||
"acceptEdits",
|
||||
"auto",
|
||||
]
|
||||
)
|
||||
started = _run(command, env)
|
||||
|
||||
76
services/hermes/scripts/hermes_stt_client.py
Normal file
76
services/hermes/scripts/hermes_stt_client.py
Normal file
@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send one Hermes local-command STT request to the private Whisper service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def _multipart(audio: Path, language: str, model: str) -> tuple[bytes, str]:
|
||||
boundary = f"atlas-hermes-{secrets.token_hex(12)}"
|
||||
mime = mimetypes.guess_type(audio.name)[0] or "application/octet-stream"
|
||||
chunks: list[bytes] = []
|
||||
|
||||
def field(name: str, value: str) -> None:
|
||||
chunks.extend(
|
||||
[
|
||||
f"--{boundary}\r\n".encode(),
|
||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
|
||||
value.encode(),
|
||||
b"\r\n",
|
||||
]
|
||||
)
|
||||
|
||||
field("language", language)
|
||||
field("model", model)
|
||||
chunks.extend(
|
||||
[
|
||||
f"--{boundary}\r\n".encode(),
|
||||
f'Content-Disposition: form-data; name="file"; filename="{audio.name}"\r\n'.encode(),
|
||||
f"Content-Type: {mime}\r\n\r\n".encode(),
|
||||
audio.read_bytes(),
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode(),
|
||||
]
|
||||
)
|
||||
return b"".join(chunks), boundary
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Transcribe one file and emit the .txt contract Hermes expects."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input_path", type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--language", default="auto")
|
||||
parser.add_argument("--model", default="large-v3-turbo")
|
||||
args = parser.parse_args()
|
||||
|
||||
body, boundary = _multipart(args.input_path, args.language, args.model)
|
||||
request = Request(
|
||||
os.getenv(
|
||||
"HERMES_STT_URL",
|
||||
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions",
|
||||
),
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(request, timeout=120) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
transcript = str(result.get("text") or "").strip()
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = args.output_dir / f"{args.input_path.stem}.txt"
|
||||
output.write_text(transcript, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
170
services/hermes/voice-deployment.yaml
Normal file
170
services/hermes/voice-deployment.yaml
Normal file
@ -0,0 +1,170 @@
|
||||
# services/hermes/voice-deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-stt
|
||||
namespace: hermes
|
||||
labels:
|
||||
app: hermes-stt
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-stt
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-stt
|
||||
annotations:
|
||||
ai.bstein.dev/role: private-chat-speech-to-text
|
||||
ai.bstein.dev/model: whisper-turbo
|
||||
ai.bstein.dev/gpu: titan-21 dedicated speech time-slice
|
||||
spec:
|
||||
runtimeClassName: nvidia
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: titan-21
|
||||
containers:
|
||||
- name: stt
|
||||
image: registry.bstein.dev/bstein/hermes-jetson-stt@sha256:a67494014716a65152b7595122b1717a742db206c6f556c740fecc110452f65b
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9000, protocol: TCP}
|
||||
env:
|
||||
- {name: HOME, value: /tmp}
|
||||
- {name: XDG_CACHE_HOME, value: /tmp/cache}
|
||||
- {name: HERMES_STT_MODEL, value: large-v3-turbo}
|
||||
- {name: HERMES_STT_CACHE, value: /opt/models/whisper}
|
||||
- {name: NVIDIA_VISIBLE_DEVICES, value: all}
|
||||
- {name: NVIDIA_DRIVER_CAPABILITIES, value: compute,utility}
|
||||
- {name: JETSON_JETPACK, value: "5"}
|
||||
startupProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 90
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
resources:
|
||||
requests:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
nvidia.com/gpu.shared: 1
|
||||
limits:
|
||||
cpu: "6"
|
||||
memory: 10Gi
|
||||
nvidia.com/gpu.shared: 1
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes-stt
|
||||
namespace: hermes
|
||||
spec:
|
||||
selector:
|
||||
app: hermes-stt
|
||||
ports:
|
||||
- {name: http, port: 9000, targetPort: http, protocol: TCP}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hermes-tts
|
||||
namespace: hermes
|
||||
labels:
|
||||
app: hermes-tts
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hermes-tts
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hermes-tts
|
||||
annotations:
|
||||
ai.bstein.dev/role: private-chat-text-to-speech
|
||||
ai.bstein.dev/model: piper-en-us-lessac-high
|
||||
ai.bstein.dev/gpu: CPU-only on routing node
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: titan-20
|
||||
containers:
|
||||
- name: tts
|
||||
image: registry.bstein.dev/bstein/hermes-jetson-tts@sha256:6483ca4e89d0663c770b7b3e3c9c9ff85d457ae877f2e83c55765cadc901d9b7
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9001, protocol: TCP}
|
||||
env:
|
||||
- {name: HOME, value: /tmp}
|
||||
- {name: XDG_CACHE_HOME, value: /tmp/cache}
|
||||
- {name: HERMES_TTS_VOICE, value: en_US-lessac-high}
|
||||
- {name: HERMES_TTS_CACHE, value: /opt/models/piper}
|
||||
startupProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 60
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readOnlyRootFilesystem: true
|
||||
runAsUser: 10000
|
||||
runAsGroup: 10000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
resources:
|
||||
requests: {cpu: 500m, memory: 512Mi}
|
||||
limits: {cpu: "2", memory: 2Gi}
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hermes-tts
|
||||
namespace: hermes
|
||||
spec:
|
||||
selector:
|
||||
app: hermes-tts
|
||||
ports:
|
||||
- {name: http, port: 9001, targetPort: http, protocol: TCP}
|
||||
@ -41,44 +41,68 @@ def test_chat_config_enables_real_research_compute_and_delegation():
|
||||
assert "code_execution" not in toolsets
|
||||
|
||||
|
||||
def test_sandbox_has_no_credentials_token_or_egress():
|
||||
def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
|
||||
sandbox_docs = _documents(HERMES / "chat-sandbox.yaml")
|
||||
statefulset = next(doc for doc in sandbox_docs if doc["kind"] == "StatefulSet")
|
||||
pod_spec = statefulset["spec"]["template"]["spec"]
|
||||
deployments = [doc for doc in sandbox_docs if doc["kind"] == "Deployment"]
|
||||
assert len(deployments) == 4
|
||||
for ordinal, deployment in enumerate(deployments):
|
||||
pod_spec = deployment["spec"]["template"]["spec"]
|
||||
container = pod_spec["containers"][0]
|
||||
|
||||
assert pod_spec["automountServiceAccountToken"] is False
|
||||
assert container["securityContext"]["readOnlyRootFilesystem"] is True
|
||||
assert container["securityContext"]["runAsNonRoot"] is True
|
||||
assert container["securityContext"]["runAsGroup"] == 10000
|
||||
assert not container.get("env")
|
||||
assert {mount["mountPath"] for mount in container["volumeMounts"]} == {
|
||||
"/tmp",
|
||||
"/workspace",
|
||||
"/opt/data/workspace",
|
||||
}
|
||||
workspace_volume = next(
|
||||
item for item in pod_spec["volumes"] if item["name"] == "workspace"
|
||||
)
|
||||
assert workspace_volume["persistentVolumeClaim"]["claimName"] == (
|
||||
f"workspace-hermes-chat-tenant-{ordinal}"
|
||||
)
|
||||
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
templates = statefulset["spec"]["volumeClaimTemplates"]
|
||||
workspace = next(item for item in templates if item["metadata"]["name"] == "workspace")
|
||||
assert workspace["spec"]["resources"]["requests"]["storage"] == "10Gi"
|
||||
assert workspace["spec"]["accessModes"] == ["ReadWriteMany"]
|
||||
|
||||
pod_spec = statefulset["spec"]["template"]["spec"]
|
||||
hermes = next(item for item in pod_spec["containers"] if item["name"] == "hermes")
|
||||
startup = hermes["args"][0]
|
||||
assert "hermes-chat-sandbox-${ordinal}.hermes-chat-sandbox" in startup
|
||||
assert any(
|
||||
mount["name"] == "workspace" and mount["mountPath"] == "/opt/data/workspace"
|
||||
for mount in hermes["volumeMounts"]
|
||||
)
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
deny = next(
|
||||
doc
|
||||
for doc in policies
|
||||
if doc["kind"] == "NetworkPolicy"
|
||||
and doc["metadata"]["name"] == "hermes-chat-sandbox-deny"
|
||||
item
|
||||
for item in policies
|
||||
if item["metadata"]["name"] == "hermes-chat-sandbox-deny"
|
||||
)
|
||||
assert deny["spec"]["policyTypes"] == ["Ingress", "Egress"]
|
||||
assert deny["spec"]["ingress"] == []
|
||||
assert deny["spec"]["egress"] == []
|
||||
for ordinal in range(4):
|
||||
policy = next(
|
||||
doc
|
||||
for doc in policies
|
||||
if doc["kind"] == "NetworkPolicy"
|
||||
and doc["metadata"]["name"] == f"hermes-chat-sandbox-tenant-{ordinal}"
|
||||
item
|
||||
for item in policies
|
||||
if item["metadata"]["name"] == f"hermes-chat-sandbox-tenant-{ordinal}"
|
||||
)
|
||||
assert policy["spec"]["podSelector"]["matchLabels"][
|
||||
"apps.kubernetes.io/pod-index"
|
||||
"ai.bstein.dev/tenant-ordinal"
|
||||
] == str(ordinal)
|
||||
source = policy["spec"]["ingress"][0]["from"][0]["podSelector"][
|
||||
"matchLabels"
|
||||
]
|
||||
assert source["apps.kubernetes.io/pod-index"] == str(ordinal)
|
||||
assert source["statefulset.kubernetes.io/pod-name"] == (
|
||||
f"hermes-chat-tenant-{ordinal}"
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
@ -114,6 +138,100 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
|
||||
assert "--cookie-csrf-per-request=true" in args
|
||||
assert "--cookie-csrf-per-request-limit=8" in args
|
||||
assert "--trusted-proxy-ip=10.42.0.0/16" in args
|
||||
assert "--api-route=^/api/" in args
|
||||
assert "--api-route=^/health$" in args
|
||||
assert "--cookie-expire=168h" in args
|
||||
|
||||
|
||||
def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
|
||||
assert "res.status===401||res.status===403" in dockerfile
|
||||
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
|
||||
assert "childrenExpanded?'▾ ':'▸ '" in dockerfile
|
||||
assert "profile default: ' + p.model" in dockerfile
|
||||
|
||||
|
||||
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
webui = next(item for item in containers if item["name"] == "webui")
|
||||
env = {item["name"]: item["value"] for item in webui["env"]}
|
||||
|
||||
assert env["HERMES_STT_URL"] == (
|
||||
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions"
|
||||
)
|
||||
assert env["HERMES_WEBUI_ATLAS_TTS_URL"] == (
|
||||
"http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
|
||||
)
|
||||
assert "hermes_stt_client.py" in env["HERMES_LOCAL_STT_COMMAND"]
|
||||
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["stt"] == {
|
||||
"enabled": True,
|
||||
"provider": "local_command",
|
||||
"local": {"model": "large-v3-turbo", "language": "auto"},
|
||||
}
|
||||
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
assert "hermes-webui-atlas-patch.py" in dockerfile
|
||||
assert "hermes-webui-atlas-voice.js" in dockerfile
|
||||
voice_script = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js").read_text()
|
||||
assert "/api/transcribe/capability" in voice_script
|
||||
assert "/api/transcribe" in voice_script
|
||||
assert "/api/tts" in voice_script
|
||||
assert "speakResponse(generation)" in voice_script
|
||||
assert "restartSoon(token,450)" in voice_script
|
||||
|
||||
|
||||
def test_voice_models_are_baked_and_runtime_has_no_public_egress():
|
||||
stt_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt").read_text()
|
||||
tts_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-tts").read_text()
|
||||
assert "ADD --checksum=sha256:aff26ae4" in stt_dockerfile
|
||||
assert "--chmod=0444" in stt_dockerfile
|
||||
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
|
||||
assert tts_dockerfile.count("--chmod=0444") == 2
|
||||
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
|
||||
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
|
||||
assert "download_voice" not in tts_server
|
||||
assert "baked Piper voice is missing" in tts_server
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
voice_policy = next(
|
||||
item for item in policies if item["metadata"]["name"] == "hermes-private-voice"
|
||||
)
|
||||
assert voice_policy["spec"]["policyTypes"] == ["Ingress", "Egress"]
|
||||
assert not any(
|
||||
"ipBlock" in destination
|
||||
for rule in voice_policy["spec"]["egress"]
|
||||
for destination in rule.get("to", [])
|
||||
)
|
||||
|
||||
|
||||
def test_voice_workloads_have_deliberate_xavier_placement():
|
||||
documents = _documents(HERMES / "voice-deployment.yaml")
|
||||
deployments = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in documents
|
||||
if item["kind"] == "Deployment"
|
||||
}
|
||||
stt = deployments["hermes-stt"]["spec"]["template"]["spec"]
|
||||
tts = deployments["hermes-tts"]["spec"]["template"]["spec"]
|
||||
|
||||
assert "@sha256:" in stt["containers"][0]["image"]
|
||||
assert "@sha256:" in tts["containers"][0]["image"]
|
||||
assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-20"}
|
||||
assert stt["runtimeClassName"] == "nvidia"
|
||||
stt_resources = stt["containers"][0]["resources"]
|
||||
assert stt_resources["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert stt_resources["limits"]["nvidia.com/gpu.shared"] == 1
|
||||
assert "nvidia.com/gpu.shared" not in tts["containers"][0]["resources"]["requests"]
|
||||
assert all("hostPath" not in volume for volume in stt["volumes"])
|
||||
assert all("hostPath" not in volume for volume in tts["volumes"])
|
||||
|
||||
|
||||
def test_chat_auth_file_mount_survives_atomic_provider_refresh():
|
||||
|
||||
@ -82,7 +82,7 @@ def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
|
||||
"wait-output",
|
||||
"w2:p1",
|
||||
"--match",
|
||||
"accept edits on",
|
||||
"Claude Code",
|
||||
"--source",
|
||||
"recent",
|
||||
"--lines",
|
||||
@ -94,7 +94,7 @@ def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
|
||||
assert prompt[1:5] == [
|
||||
"agent",
|
||||
"prompt",
|
||||
"claude-review",
|
||||
"project-claude-review",
|
||||
"Check the implementation.",
|
||||
]
|
||||
assert prompt[-9:] == [
|
||||
@ -109,6 +109,53 @@ def test_claude_worker_waits_for_prompt_readiness(tmp_path: Path, monkeypatch):
|
||||
"15000",
|
||||
]
|
||||
|
||||
workspace_create = calls[0]
|
||||
assert workspace_create[-3:] == ["--label", "project-claude-review", "--no-focus"]
|
||||
start = calls[1]
|
||||
assert start[1:4] == ["agent", "start", "project-claude-review"]
|
||||
assert start[-2:] == ["--permission-mode", "auto"]
|
||||
|
||||
|
||||
def test_codex_worker_uses_automatic_review_without_prompts(tmp_path: Path, monkeypatch):
|
||||
herdr = tmp_path / "herdr"
|
||||
herdr.touch()
|
||||
project = tmp_path / "cassandra"
|
||||
project.mkdir()
|
||||
auth = tmp_path / "auth.json"
|
||||
auth.write_text("{}", encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
def fake_run(command, env):
|
||||
calls.append(command)
|
||||
if command[1:3] == ["workspace", "create"]:
|
||||
return {"result": {"root_pane": {"pane_id": "w3:p1"}}}
|
||||
return {"result": {"ok": True}}
|
||||
|
||||
monkeypatch.setattr(dispatch, "HERDR_BIN", herdr)
|
||||
monkeypatch.setattr(dispatch, "CODEX_AUTH", auth)
|
||||
monkeypatch.setattr(dispatch, "_run", fake_run)
|
||||
plan = {"worker": "codex", "model": "gpt-5.6-sol", "effort": "high"}
|
||||
|
||||
dispatch.launch_worker(plan, project, "api-fix", None)
|
||||
|
||||
assert calls[0][-3:] == ["--label", "cassandra-codex-api-fix", "--no-focus"]
|
||||
start = calls[1]
|
||||
assert start[1:4] == ["agent", "start", "cassandra-codex-api-fix"]
|
||||
assert "--approve-for-me" in start
|
||||
assert start[-2:] == ["--sandbox", "workspace-write"]
|
||||
assert not any("on-request" in value for value in start)
|
||||
|
||||
|
||||
def test_agent_config_uses_bounded_noninteractive_approvals():
|
||||
configmap = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
|
||||
assert config["approvals"]["mode"] == "off"
|
||||
denied = config["approvals"]["deny"]
|
||||
assert "*kubectl apply*" in denied
|
||||
assert "*git push --force*" in denied
|
||||
assert "*git reset --hard*" in denied
|
||||
|
||||
|
||||
def test_auth_patch_honors_explicit_shared_store(tmp_path: Path):
|
||||
source = tmp_path / "auth.py"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user