Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
77f10fa611 feat: expose cron runtime overrides for burn-loop pinning (#799)
All checks were successful
Lint / lint (pull_request) Successful in 16s
Add cron CLI flags for per-job model, provider, and base URL overrides,
forward them through hermes_cli.cron, and print pinned runtime overrides
in create/edit/list output so Gemma burn-loop jobs are auditable.
2026-04-22 11:02:47 -04:00
6 changed files with 172 additions and 112 deletions

33
cli.py
View File

@@ -6852,12 +6852,11 @@ class HermesCLI:
self._voice_stop_and_transcribe()
# Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)
if self._voice_beeps_enabled():
try:
from tools.voice_mode import play_beep
play_beep(frequency=880, count=1)
except Exception:
pass
try:
from tools.voice_mode import play_beep
play_beep(frequency=880, count=1)
except Exception:
pass
try:
self._voice_recorder.start(on_silence_stop=_on_silence)
@@ -6905,12 +6904,11 @@ class HermesCLI:
wav_path = self._voice_recorder.stop()
# Audio cue: double beep after stream stopped (no CoreAudio conflict)
if self._voice_beeps_enabled():
try:
from tools.voice_mode import play_beep
play_beep(frequency=660, count=2)
except Exception:
pass
try:
from tools.voice_mode import play_beep
play_beep(frequency=660, count=2)
except Exception:
pass
if wav_path is None:
_cprint(f"{_DIM}No speech detected.{_RST}")
@@ -7061,17 +7059,6 @@ class HermesCLI:
_cprint(f"Unknown voice subcommand: {subcommand}")
_cprint("Usage: /voice [on|off|tts|status]")
def _voice_beeps_enabled(self) -> bool:
"""Return whether CLI voice mode should play record start/stop beeps."""
try:
from hermes_cli.config import load_config
voice_cfg = load_config().get("voice", {})
if isinstance(voice_cfg, dict):
return bool(voice_cfg.get("beep_enabled", True))
except Exception:
pass
return True
def _enable_voice_mode(self):
"""Enable voice mode after checking requirements."""
if self._voice_mode:

View File

@@ -38,6 +38,18 @@ def _cron_api(**kwargs):
return json.loads(cronjob_tool(**kwargs))
def _print_runtime_overrides(job: dict) -> None:
model = job.get("model")
provider = job.get("provider")
base_url = job.get("base_url")
if model:
print(f" Model: {model}")
if provider:
print(f" Provider: {provider}")
if base_url:
print(f" Base URL: {base_url}")
def cron_list(show_all: bool = False):
"""List all scheduled jobs."""
from cron.jobs import list_jobs
@@ -93,6 +105,7 @@ def cron_list(show_all: bool = False):
script = job.get("script")
if script:
print(f" Script: {script}")
_print_runtime_overrides(job)
# Execution history
last_status = job.get("last_status")
@@ -167,6 +180,9 @@ def cron_create(args):
repeat=getattr(args, "repeat", None),
skill=getattr(args, "skill", None),
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
base_url=getattr(args, "base_url", None),
script=getattr(args, "script", None),
)
if not result.get("success"):
@@ -180,6 +196,8 @@ def cron_create(args):
job_data = result.get("job", {})
if job_data.get("script"):
print(f" Script: {job_data['script']}")
if job_data:
_print_runtime_overrides(job_data)
print(f" Next run: {result['next_run_at']}")
return 0
@@ -217,6 +235,9 @@ def cron_edit(args):
deliver=getattr(args, "deliver", None),
repeat=getattr(args, "repeat", None),
skills=final_skills,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
base_url=getattr(args, "base_url", None),
script=getattr(args, "script", None),
)
if not result.get("success"):
@@ -233,6 +254,7 @@ def cron_edit(args):
print(" Skills: none")
if updated.get("script"):
print(f" Script: {updated['script']}")
_print_runtime_overrides(updated)
return 0

View File

@@ -4958,6 +4958,9 @@ For more help on a command:
cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id")
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
cron_create.add_argument("--model", help="Pin this job to a specific model (for example: google/gemma-4-31b-it)")
cron_create.add_argument("--provider", help="Pin this job to a specific provider (for example: openrouter)")
cron_create.add_argument("--base-url", dest="base_url", help="Optional base URL override for the job's runtime provider")
cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run")
# cron edit
@@ -4972,6 +4975,9 @@ For more help on a command:
cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.")
cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.")
cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job")
cron_edit.add_argument("--model", help="Update the job's pinned model")
cron_edit.add_argument("--provider", help="Update the job's pinned provider")
cron_edit.add_argument("--base-url", dest="base_url", help="Update the job's pinned base URL. Pass an empty string to clear it.")
cron_edit.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.")
# lifecycle actions

View File

@@ -1,6 +1,7 @@
"""Tests for hermes_cli.cron command handling."""
from argparse import Namespace
from unittest.mock import patch
import pytest
@@ -105,3 +106,135 @@ class TestCronCommandLifecycle:
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"]
assert jobs[0]["name"] == "Skill combo"
def test_create_can_pin_runtime_model_provider_and_base_url(self, tmp_cron_dir, capsys):
cron_command(
Namespace(
cron_command="create",
schedule="every 1h",
prompt="Run the burn loop",
name="Gemma burn",
deliver=None,
repeat=None,
skill=None,
skills=None,
script=None,
model="google/gemma-4-31b-it",
provider="openrouter",
base_url="https://openrouter.ai/api/v1",
)
)
job = list_jobs()[0]
assert job["model"] == "google/gemma-4-31b-it"
assert job["provider"] == "openrouter"
assert job["base_url"] == "https://openrouter.ai/api/v1"
out = capsys.readouterr().out
assert "Created job" in out
assert "Model: google/gemma-4-31b-it" in out
assert "Provider: openrouter" in out
def test_edit_can_update_runtime_model_provider_and_clear_base_url(self, tmp_cron_dir, capsys):
job = create_job(prompt="Check server status", schedule="every 1h")
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule=None,
prompt=None,
name=None,
deliver=None,
repeat=None,
skill=None,
skills=None,
add_skills=None,
remove_skills=None,
clear_skills=False,
script=None,
model="google/gemma-4-31b-it",
provider="openrouter",
base_url="",
)
)
updated = get_job(job["id"])
assert updated["model"] == "google/gemma-4-31b-it"
assert updated["provider"] == "openrouter"
assert updated["base_url"] is None
out = capsys.readouterr().out
assert "Updated job" in out
assert "Model: google/gemma-4-31b-it" in out
assert "Provider: openrouter" in out
class TestCronParserRuntimeOverrideFlags:
def test_main_parses_create_runtime_override_flags(self, monkeypatch):
from hermes_cli import main as main_mod
captured = {}
def fake_cmd_cron(args):
captured["args"] = args
monkeypatch.setattr(main_mod, "cmd_cron", fake_cmd_cron)
monkeypatch.setattr(
"sys.argv",
[
"hermes",
"cron",
"create",
"every 1h",
"Run the burn loop",
"--model",
"google/gemma-4-31b-it",
"--provider",
"openrouter",
"--base-url",
"https://openrouter.ai/api/v1",
],
)
main_mod.main()
args = captured["args"]
assert args.cron_command == "create"
assert args.model == "google/gemma-4-31b-it"
assert args.provider == "openrouter"
assert args.base_url == "https://openrouter.ai/api/v1"
def test_main_parses_edit_runtime_override_flags(self, monkeypatch):
from hermes_cli import main as main_mod
captured = {}
def fake_cmd_cron(args):
captured["args"] = args
monkeypatch.setattr(main_mod, "cmd_cron", fake_cmd_cron)
monkeypatch.setattr(
"sys.argv",
[
"hermes",
"cron",
"edit",
"job123",
"--model",
"google/gemma-4-31b-it",
"--provider",
"openrouter",
"--base-url",
"",
],
)
main_mod.main()
args = captured["args"]
assert args.cron_command == "edit"
assert args.job_id == "job123"
assert args.model == "google/gemma-4-31b-it"
assert args.provider == "openrouter"
assert args.base_url == ""

View File

@@ -4,31 +4,13 @@ state management, streaming TTS activation, voice message prefix, _vprint."""
import ast
import os
import queue
import sys
import threading
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _ensure_cli_import_shims():
sys.modules.setdefault(
"agent.auxiliary_client",
types.SimpleNamespace(
call_llm=lambda *args, **kwargs: "",
async_call_llm=lambda *args, **kwargs: "",
extract_content_or_reasoning=lambda *args, **kwargs: "",
resolve_provider_client=lambda *args, **kwargs: (None, None, None, None),
get_async_text_auxiliary_client=lambda *args, **kwargs: None,
),
)
_ensure_cli_import_shims()
def _make_voice_cli(**overrides):
"""Create a minimal HermesCLI with only voice-related attrs initialized.
@@ -36,7 +18,6 @@ def _make_voice_cli(**overrides):
needed. Only the voice state attributes (from __init__ lines 3749-3758)
are populated.
"""
_ensure_cli_import_shims()
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
@@ -952,58 +933,6 @@ class TestEnableVoiceModeReal:
assert cli._voice_mode is True
class TestVoiceBeepConfigReal:
"""Tests the CLI voice beep toggle."""
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
def test_beeps_enabled_by_default(self, _cfg):
cli = _make_voice_cli()
assert cli._voice_beeps_enabled() is True
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
def test_beeps_can_be_disabled(self, _cfg):
cli = _make_voice_cli()
assert cli._voice_beeps_enabled() is False
@patch("cli._cprint")
@patch("cli.threading.Thread")
@patch("tools.voice_mode.play_beep")
@patch("tools.voice_mode.create_audio_recorder")
@patch(
"tools.voice_mode.check_voice_requirements",
return_value={
"available": True,
"audio_available": True,
"stt_available": True,
"details": "OK",
"missing_packages": [],
},
)
@patch(
"hermes_cli.config.load_config",
return_value={
"voice": {
"beep_enabled": False,
"silence_threshold": 200,
"silence_duration": 3.0,
}
},
)
def test_start_recording_skips_beep_when_disabled(
self, _cfg, _req, mock_create, mock_beep, mock_thread, _cp
):
recorder = MagicMock()
recorder.supports_silence_autostop = True
mock_create.return_value = recorder
mock_thread.return_value = MagicMock(start=MagicMock())
cli = _make_voice_cli()
cli._voice_start_recording()
recorder.start.assert_called_once()
mock_beep.assert_not_called()
class TestDisableVoiceModeReal:
"""Tests _disable_voice_mode with real CLI instance."""
@@ -1158,16 +1087,6 @@ class TestVoiceStopAndTranscribeReal:
cli._voice_stop_and_transcribe()
assert cli._pending_input.empty()
@patch("cli._cprint")
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
@patch("tools.voice_mode.play_beep")
def test_no_speech_detected_skips_beep_when_disabled(self, mock_beep, _cfg, _cp):
recorder = MagicMock()
recorder.stop.return_value = None
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
cli._voice_stop_and_transcribe()
mock_beep.assert_not_called()
@patch("cli._cprint")
@patch("cli.os.unlink")
@patch("cli.os.path.isfile", return_value=True)
@@ -1237,18 +1156,12 @@ class TestVoiceStopAndTranscribeReal:
@patch("cli._cprint")
@patch("tools.voice_mode.play_beep")
def test_continuous_restarts_on_no_speech(self, _beep, _cp):
import time
recorder = MagicMock()
recorder.stop.return_value = None
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
_voice_continuous=True)
cli._voice_start_recording = MagicMock()
cli._voice_stop_and_transcribe()
for _ in range(50):
if cli._voice_start_recording.call_count:
break
time.sleep(0.01)
cli._voice_start_recording.assert_called_once()
@patch("cli._cprint")

View File

@@ -149,7 +149,7 @@ Two-stage algorithm detects when you've finished speaking:
If no speech is detected at all for 15 seconds, recording stops automatically.
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`. You can also disable the record start/stop beeps with `voice.beep_enabled: false`.
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`.
### Streaming TTS
@@ -383,7 +383,6 @@ voice:
record_key: "ctrl+b" # Key to start/stop recording
max_recording_seconds: 120 # Maximum recording length
auto_tts: false # Auto-enable TTS when voice mode starts
beep_enabled: true # Play record start/stop beeps
silence_threshold: 200 # RMS level (0-32767) below which counts as silence
silence_duration: 3.0 # Seconds of silence before auto-stop