mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-13 03:52:00 +00:00
feat(gateway): per-platform gateway_restart_notification flag
Adds an opt-out toggle on PlatformConfig that gates both restart
lifecycle pings: the "♻ Gateway restarted" message sent to the chat
that issued /restart, and the "♻️ Gateway online" home-channel
startup notification. Defaults to True so existing deployments are
unaffected.
The motivating split is operator vs. end-user surfaces: a back-channel
like Telegram should keep these pings, while a Slack workspace shared
with end users should not surface gateway lifecycle noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33bf5f6292
commit
b71f80e6ce
4 changed files with 120 additions and 4 deletions
|
|
@ -271,15 +271,23 @@ class PlatformConfig:
|
||||||
# - "first": Only first chunk threads to user's message (default)
|
# - "first": Only first chunk threads to user's message (default)
|
||||||
# - "all": All chunks in multi-part replies thread to user's message
|
# - "all": All chunks in multi-part replies thread to user's message
|
||||||
reply_to_mode: str = "first"
|
reply_to_mode: str = "first"
|
||||||
|
|
||||||
|
# Whether the gateway is allowed to send "♻️ Gateway online" /
|
||||||
|
# "♻ Gateway restarted" lifecycle notifications on this platform.
|
||||||
|
# Default True preserves prior behavior. Set False on platforms used
|
||||||
|
# by end users (e.g. Slack) where operator-flavored restart pings are
|
||||||
|
# noise; keep True for back-channels where the operator wants them.
|
||||||
|
gateway_restart_notification: bool = True
|
||||||
|
|
||||||
# Platform-specific settings
|
# Platform-specific settings
|
||||||
extra: Dict[str, Any] = field(default_factory=dict)
|
extra: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
result = {
|
result = {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"extra": self.extra,
|
"extra": self.extra,
|
||||||
"reply_to_mode": self.reply_to_mode,
|
"reply_to_mode": self.reply_to_mode,
|
||||||
|
"gateway_restart_notification": self.gateway_restart_notification,
|
||||||
}
|
}
|
||||||
if self.token:
|
if self.token:
|
||||||
result["token"] = self.token
|
result["token"] = self.token
|
||||||
|
|
@ -288,19 +296,22 @@ class PlatformConfig:
|
||||||
if self.home_channel:
|
if self.home_channel:
|
||||||
result["home_channel"] = self.home_channel.to_dict()
|
result["home_channel"] = self.home_channel.to_dict()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
||||||
home_channel = None
|
home_channel = None
|
||||||
if "home_channel" in data:
|
if "home_channel" in data:
|
||||||
home_channel = HomeChannel.from_dict(data["home_channel"])
|
home_channel = HomeChannel.from_dict(data["home_channel"])
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
enabled=_coerce_bool(data.get("enabled"), False),
|
enabled=_coerce_bool(data.get("enabled"), False),
|
||||||
token=data.get("token"),
|
token=data.get("token"),
|
||||||
api_key=data.get("api_key"),
|
api_key=data.get("api_key"),
|
||||||
home_channel=home_channel,
|
home_channel=home_channel,
|
||||||
reply_to_mode=data.get("reply_to_mode", "first"),
|
reply_to_mode=data.get("reply_to_mode", "first"),
|
||||||
|
gateway_restart_notification=_coerce_bool(
|
||||||
|
data.get("gateway_restart_notification"), True
|
||||||
|
),
|
||||||
extra=data.get("extra", {}),
|
extra=data.get("extra", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11386,6 +11386,14 @@ class GatewayRunner:
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
platform_cfg = self.config.platforms.get(platform)
|
||||||
|
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||||
|
logger.info(
|
||||||
|
"Restart notification suppressed: %s has gateway_restart_notification=false",
|
||||||
|
platform_str,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
metadata = {"thread_id": thread_id} if thread_id else None
|
metadata = {"thread_id": thread_id} if thread_id else None
|
||||||
result = await adapter.send(
|
result = await adapter.send(
|
||||||
str(chat_id),
|
str(chat_id),
|
||||||
|
|
@ -11437,6 +11445,14 @@ class GatewayRunner:
|
||||||
if not home or not home.chat_id:
|
if not home or not home.chat_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
platform_cfg = self.config.platforms.get(platform)
|
||||||
|
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||||
|
logger.info(
|
||||||
|
"Home-channel startup notification suppressed: %s has gateway_restart_notification=false",
|
||||||
|
platform.value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
target = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None)
|
target = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None)
|
||||||
if target in skipped or target in delivered:
|
if target in skipped or target in delivered:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,19 @@ class TestPlatformConfigRoundtrip:
|
||||||
restored = PlatformConfig.from_dict({"enabled": "false"})
|
restored = PlatformConfig.from_dict({"enabled": "false"})
|
||||||
assert restored.enabled is False
|
assert restored.enabled is False
|
||||||
|
|
||||||
|
def test_gateway_restart_notification_defaults_true(self):
|
||||||
|
assert PlatformConfig().gateway_restart_notification is True
|
||||||
|
assert PlatformConfig.from_dict({}).gateway_restart_notification is True
|
||||||
|
|
||||||
|
def test_gateway_restart_notification_roundtrip_false(self):
|
||||||
|
pc = PlatformConfig(enabled=True, gateway_restart_notification=False)
|
||||||
|
restored = PlatformConfig.from_dict(pc.to_dict())
|
||||||
|
assert restored.gateway_restart_notification is False
|
||||||
|
|
||||||
|
def test_gateway_restart_notification_coerces_quoted_false(self):
|
||||||
|
restored = PlatformConfig.from_dict({"gateway_restart_notification": "false"})
|
||||||
|
assert restored.gateway_restart_notification is False
|
||||||
|
|
||||||
|
|
||||||
class TestGetConnectedPlatforms:
|
class TestGetConnectedPlatforms:
|
||||||
def test_returns_enabled_with_token(self):
|
def test_returns_enabled_with_token(self):
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,82 @@ async def test_send_restart_notification_logs_warning_on_sendresult_failure(
|
||||||
assert not notify_path.exists()
|
assert not notify_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_home_channel_startup_notification_skipped_when_flag_disabled(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Per-platform opt-out: gateway_restart_notification=False mutes the home-channel ping."""
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||||
|
|
||||||
|
runner, adapter = make_restart_runner()
|
||||||
|
runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
|
||||||
|
platform=Platform.TELEGRAM,
|
||||||
|
chat_id="home-42",
|
||||||
|
name="Ops Home",
|
||||||
|
)
|
||||||
|
runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False
|
||||||
|
adapter.send = AsyncMock()
|
||||||
|
|
||||||
|
delivered = await runner._send_home_channel_startup_notifications()
|
||||||
|
|
||||||
|
assert delivered == set()
|
||||||
|
adapter.send.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_home_channel_startup_notification_default_flag_true(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Default behavior is unchanged: missing flag means notifications still fire."""
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||||
|
|
||||||
|
runner, adapter = make_restart_runner()
|
||||||
|
# Sanity-check the dataclass default — guards against future refactors
|
||||||
|
# silently flipping the default to False.
|
||||||
|
assert runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification is True
|
||||||
|
|
||||||
|
runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
|
||||||
|
platform=Platform.TELEGRAM,
|
||||||
|
chat_id="home-42",
|
||||||
|
name="Ops Home",
|
||||||
|
)
|
||||||
|
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home"))
|
||||||
|
|
||||||
|
delivered = await runner._send_home_channel_startup_notifications()
|
||||||
|
|
||||||
|
assert delivered == {("telegram", "home-42", None)}
|
||||||
|
adapter.send.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_restart_notification_skipped_when_flag_disabled(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""The /restart originator's notification also honors the per-platform flag.
|
||||||
|
|
||||||
|
Slack used by end users → flag off → no "Gateway restarted" message even
|
||||||
|
when an end user accidentally triggers /restart. The marker file is still
|
||||||
|
cleaned up so the notification doesn't leak into the next boot.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||||
|
|
||||||
|
notify_path = tmp_path / ".restart_notify.json"
|
||||||
|
notify_path.write_text(json.dumps({
|
||||||
|
"platform": "telegram",
|
||||||
|
"chat_id": "42",
|
||||||
|
}))
|
||||||
|
|
||||||
|
runner, adapter = make_restart_runner()
|
||||||
|
runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False
|
||||||
|
adapter.send = AsyncMock()
|
||||||
|
|
||||||
|
delivered_target = await runner._send_restart_notification()
|
||||||
|
|
||||||
|
assert delivered_target is None
|
||||||
|
adapter.send.assert_not_called()
|
||||||
|
assert not notify_path.exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_restart_notification_logs_info_on_sendresult_success(
|
async def test_send_restart_notification_logs_info_on_sendresult_success(
|
||||||
tmp_path, monkeypatch, caplog
|
tmp_path, monkeypatch, caplog
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue