feat: make busy steer ack configurable

This commit is contained in:
devatnull 2026-06-29 02:11:30 +03:00 committed by Teknium
parent 46fbd73f66
commit 12f03b11ff
4 changed files with 96 additions and 1 deletions

View file

@ -47,6 +47,11 @@ _GLOBAL_DEFAULTS: dict[str, Any] = {
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
# Whether busy_input_mode=steer sends a visible "Steered into current run"
# acknowledgment after successfully injecting the user's mid-turn message.
# Disable when the platform should steer silently (the text still lands in
# the active run; only the confirmation echo is suppressed).
"busy_steer_ack_enabled": True,
# When true, delete tool-progress / "⏳ Working — N min" / status bubbles
# after the final response lands on platforms that support message
# deletion (e.g. Telegram). Off by default — progress is still shown

View file

@ -1563,6 +1563,8 @@ if _config_path.exists():
os.environ["HERMES_GATEWAY_BUSY_TEXT_MODE"] = str(_display_cfg["busy_text_mode"])
if "busy_ack_enabled" in _display_cfg:
os.environ["HERMES_GATEWAY_BUSY_ACK_ENABLED"] = str(_display_cfg["busy_ack_enabled"])
if "busy_steer_ack_enabled" in _display_cfg:
os.environ["HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED"] = str(_display_cfg["busy_steer_ack_enabled"])
# Timezone: bridge config.yaml → HERMES_TIMEZONE env var.
_tz_cfg = _cfg.get("timezone", "")
if _tz_cfg and isinstance(_tz_cfg, str):
@ -5330,6 +5332,30 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("Busy ack suppressed for session %s", session_key)
return True # input still processed, just no ack sent
from gateway.display_config import resolve_display_setting
platform_key = _platform_config_key(event.source.platform)
# In steer mode the user's text has already been injected into the
# active run. Some mobile chat setups want that steering to be silent,
# like STT transcript echo suppression: keep the behavior, drop only
# the confirmation bubble.
if is_steer_mode:
steer_ack_env = os.environ.get("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED")
if steer_ack_env is not None:
steer_ack_enabled = steer_ack_env.strip().lower() in {"1", "true", "yes", "on"}
else:
steer_ack_enabled = bool(
resolve_display_setting(
_load_gateway_config(),
platform_key,
"busy_steer_ack_enabled",
True,
)
)
if not steer_ack_enabled:
logger.debug("Busy steer ack suppressed for session %s", session_key)
return True
# Debounce: only send an acknowledgment once every 30 seconds per session
# to avoid spamming the user when they send multiple messages quickly
_BUSY_ACK_COOLDOWN = 30
@ -5343,7 +5369,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Build a status-rich acknowledgment. Mobile chat defaults keep this
# terse; detailed iteration/tool state is still available in logs and
# can be opted in per platform via display.platforms.<platform>.busy_ack_detail.
from gateway.display_config import resolve_display_setting
status_parts = []
busy_ack_detail_enabled = bool(
resolve_display_setting(

View file

@ -1675,6 +1675,10 @@ DEFAULT_CONFIG = {
# behavior of showing tool-call summaries inline.
"resume_skip_tool_only": True,
"busy_input_mode": "interrupt", # interrupt | queue | steer
# When busy_input_mode="steer", suppress only the visible
# "Steered into current run" confirmation bubble by setting this false.
# The mid-turn steering itself still happens.
"busy_steer_ack_enabled": True,
# Which interface bare `hermes` (and `hermes chat`) launches by default:
# "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior)
# "tui" — the modern Ink TUI (same as passing `--tui`)

View file

@ -297,6 +297,67 @@ class TestBusySessionAck:
assert "Steered" in content or "steer" in content.lower()
assert "Interrupting" not in content
@pytest.mark.asyncio
async def test_steer_mode_can_suppress_visible_ack_without_disabling_steer(self, monkeypatch):
"""busy_steer_ack_enabled=false keeps steering but drops the echo bubble."""
import gateway.run as _gr
monkeypatch.delenv("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED", raising=False)
monkeypatch.setattr(
_gr,
"_load_gateway_config",
lambda: {"display": {"platforms": {"telegram": {"busy_steer_ack_enabled": False}}}},
)
runner, sentinel = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="also check the tests")
sk = build_session_key(event.source)
runner.adapters[event.source.platform] = adapter
agent = MagicMock()
agent.steer = MagicMock(return_value=True)
runner._running_agents[sk] = agent
await runner._handle_active_session_busy_message(event, sk)
agent.steer.assert_called_once_with("also check the tests")
agent.interrupt.assert_not_called()
adapter._send_with_retry.assert_not_called()
assert sk not in adapter._pending_messages
@pytest.mark.asyncio
async def test_steer_ack_env_override_can_suppress_visible_ack(self, monkeypatch):
"""Env override supports process-level suppression for gateway services."""
import gateway.run as _gr
monkeypatch.setenv("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED", "false")
monkeypatch.setattr(
_gr,
"_load_gateway_config",
lambda: {"display": {"platforms": {"telegram": {"busy_steer_ack_enabled": True}}}},
)
runner, sentinel = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="steer silently")
sk = build_session_key(event.source)
runner.adapters[event.source.platform] = adapter
agent = MagicMock()
agent.steer = MagicMock(return_value=True)
runner._running_agents[sk] = agent
await runner._handle_active_session_busy_message(event, sk)
agent.steer.assert_called_once_with("steer silently")
adapter._send_with_retry.assert_not_called()
assert sk not in adapter._pending_messages
@pytest.mark.asyncio
async def test_steer_mode_falls_back_to_queue_when_agent_rejects(self):
"""If agent.steer() returns False, fall back to queue behavior."""