Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
3b910aa
feat(gateway): make still-working heartbeats configurable
thesammygit Apr 10, 2026
4694c21
fix(gateway): preserve still-working activity detail
thesammygit Apr 11, 2026
497921a
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 13, 2026
9b8645e
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 13, 2026
e911972
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 16, 2026
2850a8e
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 17, 2026
19e9aff
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 17, 2026
54308a8
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 18, 2026
68798df
test: sync config migration assertion after main merge
thesammygit Apr 18, 2026
d43793e
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 18, 2026
40e54a0
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 19, 2026
74cb955
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 19, 2026
859a980
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 19, 2026
9e1009f
Merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 20, 2026
56aeca6
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 20, 2026
3833baa
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 20, 2026
8b05412
Merge remote-tracking branch 'origin/main' into pr7446-watch
thesammygit Apr 21, 2026
be01262
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 21, 2026
21e8f91
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 21, 2026
74cd10e
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 21, 2026
fddfe91
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 22, 2026
8ed4196
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 22, 2026
d84bafe
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 22, 2026
35123d0
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 22, 2026
ba0c4c4
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 23, 2026
3e159ee
merge origin/main into sam/signal-heartbeat-config
thesammygit Apr 23, 2026
083c27a
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 23, 2026
38631f1
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 23, 2026
6d7f1b0
Merge remote-tracking branch 'origin/main' into HEAD
thesammygit Apr 24, 2026
e1f6c91
Merge remote-tracking branch 'origin/main' into sam/signal-heartbeat-…
thesammygit Apr 24, 2026
f75a5c5
merge origin/main into sam/signal-heartbeat-config
Apr 24, 2026
e122a5c
Merge remote-tracking branch 'origin/main' into HEAD
Apr 24, 2026
0a4d8b4
Merge remote-tracking branch 'origin/main' into HEAD
Apr 25, 2026
e1654ec
merge origin/main into signal heartbeat config
Apr 25, 2026
c5d3b24
Merge remote-tracking branch 'origin/main' into HEAD
Apr 25, 2026
612f725
Merge remote-tracking branch 'origin/main' into HEAD
Apr 25, 2026
390fcc7
merge origin/main into signal heartbeat config
Apr 26, 2026
ab9a455
chore: merge origin/main into signal heartbeat config
Apr 26, 2026
fec1812
Merge remote-tracking branch 'origin/main' into HEAD
Apr 26, 2026
35c52b9
Merge remote-tracking branch 'origin/main' into HEAD
Apr 27, 2026
1665603
Merge remote-tracking branch 'origin/main' into HEAD
Apr 27, 2026
c76c28f
Merge remote-tracking branch 'origin/main' into HEAD
Apr 27, 2026
71ea2a8
Merge remote-tracking branch 'origin/main' into HEAD
Apr 27, 2026
196a3cc
merge: sync signal heartbeat config with origin/main
Apr 28, 2026
27530b0
merge: sync signal heartbeat config with origin/main
Apr 28, 2026
f4aaa97
Merge remote-tracking branch 'origin/main' into HEAD
Apr 28, 2026
f359e37
Merge remote-tracking branch 'origin/main' into HEAD
Apr 28, 2026
d387bfe
Merge remote-tracking branch 'origin/main' into HEAD
Apr 28, 2026
b124eaa
Merge remote-tracking branch 'origin/main' into HEAD
Apr 29, 2026
a1e720f
Merge remote-tracking branch 'origin/main' into HEAD
Apr 29, 2026
9e90b27
Merge remote-tracking branch 'origin/main' into HEAD
Apr 29, 2026
dcbee3a
Merge remote-tracking branch 'origin/main' into HEAD
Apr 29, 2026
5390aa8
chore: merge origin/main into signal heartbeat config
Apr 30, 2026
ef5c240
Merge remote-tracking branch 'origin/main' into HEAD
Apr 30, 2026
97f7bfb
Merge remote-tracking branch 'origin/main' into HEAD
Apr 30, 2026
82dbf5f
chore: merge origin/main into signal heartbeat config
May 2, 2026
2b503c1
Merge remote-tracking branch 'origin/main' into HEAD
May 6, 2026
390e8dd
chore: merge origin/main into signal heartbeat config branch
May 8, 2026
196fa82
Merge remote-tracking branch 'origin/main' into HEAD
May 10, 2026
9137c15
chore: merge origin/main into signal heartbeat config
May 12, 2026
5af6fbe
Merge remote-tracking branch 'origin/main' into HEAD
May 14, 2026
336c334
Merge origin/main into sam/signal-heartbeat-config
May 16, 2026
f3d9ec4
Merge remote-tracking branch 'refs/remotes/origin/main' into HEAD
May 18, 2026
7490602
Merge remote-tracking branch 'origin/main' into HEAD
May 20, 2026
5227e79
Merge remote-tracking branch 'origin/main' into HEAD
May 22, 2026
4eb8fe9
Merge remote-tracking branch 'origin/main' into HEAD
May 24, 2026
b591c78
Merge remote-tracking branch 'origin/main' into HEAD
May 26, 2026
d76b9e9
chore: merge origin/main into heartbeat config branch
May 28, 2026
ca359dd
chore: merge origin/main into heartbeat config branch
May 30, 2026
3252098
chore: merge origin/main into signal heartbeat config branch
Jun 1, 2026
aa5b6c7
chore: merge origin/main into signal heartbeat config
Jun 3, 2026
2487863
chore: merge origin/main into signal heartbeat config
Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 113 additions & 19 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import inspect
import json
import logging
import math
import os
import re
import shlex
Expand Down Expand Up @@ -3275,6 +3276,94 @@ def _load_background_notifications_mode() -> str:
return "all"
return mode

@staticmethod
def _load_still_working_interval(platform_key: str | None = None) -> float | None:
"""Load per-platform still-working heartbeat interval from config.

Returns seconds as a float, or ``None`` when disabled.
Config keys:
- ``display.still_working_interval`` (global default, seconds)
- ``display.still_working_overrides.<platform>`` (per-platform override)

Values of ``0``, ``false``, or ``off`` disable the heartbeat.
Invalid global values fall back to the default 600 seconds. Invalid
per-platform overrides are ignored so they inherit the global setting.
"""

def _coerce_interval(raw, *, key_name: str, invalid_fallback) -> float | None | object:
if raw is None or raw == "":
return _MISSING
if raw is False:
return None
if raw is True:
return invalid_fallback
text = str(raw).strip().lower()
if text in {"off", "false", "none"}:
return None
if text in {"on", "true"}:
return invalid_fallback
try:
value = float(raw)
except (TypeError, ValueError):
logger.warning(
"Unknown %s '%s', defaulting to %s",
key_name,
raw,
"inherit" if invalid_fallback is _MISSING else f"{invalid_fallback}s",
)
return invalid_fallback
if math.isnan(value) or math.isinf(value):
logger.warning(
"Unknown %s '%s', defaulting to %s",
key_name,
raw,
"inherit" if invalid_fallback is _MISSING else f"{invalid_fallback}s",
)
return invalid_fallback
if value <= 0:
return None
return value

_MISSING = object()
try:
import yaml as _y
cfg_path = _hermes_home / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as _f:
cfg = _y.safe_load(_f) or {}
display_cfg = cfg.get("display", {}) if isinstance(cfg, dict) else {}
else:
display_cfg = {}
except Exception:
display_cfg = {}

raw_value = _coerce_interval(
display_cfg.get("still_working_interval"),
key_name="still_working_interval",
invalid_fallback=600.0,
)
if raw_value is _MISSING:
raw_value = _coerce_interval(
os.getenv("HERMES_AGENT_NOTIFY_INTERVAL"),
key_name="HERMES_AGENT_NOTIFY_INTERVAL",
invalid_fallback=600.0,
)
interval = 600.0 if raw_value is _MISSING else raw_value

overrides = display_cfg.get("still_working_overrides", {})
if not isinstance(overrides, dict):
overrides = {}
if platform_key:
override_value = _coerce_interval(
overrides.get(platform_key),
key_name=f"still_working_overrides.{platform_key}",
invalid_fallback=_MISSING,
)
if override_value is not _MISSING:
interval = override_value

return interval

@staticmethod
def _load_provider_routing() -> dict:
"""Load OpenRouter provider routing preferences from config.yaml."""
Expand Down Expand Up @@ -18625,12 +18714,11 @@ async def monitor_for_interrupt():
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())

# Periodic "still working" notifications for long-running tasks.
# Fires every N seconds so the user knows the agent hasn't died.
# Config: agent.gateway_notify_interval in config.yaml, or
# HERMES_AGENT_NOTIFY_INTERVAL env var. Default 180s (3 min).
# 0 = disable notifications.
_NOTIFY_INTERVAL_RAW = _float_env("HERMES_AGENT_NOTIFY_INTERVAL", 180)
_NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None
# Default every 10 minutes, configurable via display.still_working_interval
# with optional per-platform overrides in display.still_working_overrides.
# Backward compat: if the new display keys are unset, honor the older
# agent.gateway_notify_interval / HERMES_AGENT_NOTIFY_INTERVAL setting.
_notify_interval = self._load_still_working_interval(platform_key)
if not bool(
resolve_display_setting(
user_config,
Expand All @@ -18639,12 +18727,12 @@ async def monitor_for_interrupt():
True,
)
):
_NOTIFY_INTERVAL = None
_notify_interval = None
_notify_start = time.time()

async def _notify_long_running():
if _NOTIFY_INTERVAL is None:
return # Notifications disabled (gateway_notify_interval: 0)
if _notify_interval is None:
return # Notifications disabled
_notify_adapter = self.adapters.get(source.platform)
if not _notify_adapter:
return
Expand All @@ -18655,7 +18743,7 @@ async def _notify_long_running():
# supported by the adapter.
_heartbeat_msg_id: Optional[str] = None
while True:
await asyncio.sleep(_NOTIFY_INTERVAL)
await asyncio.sleep(_notify_interval)
_elapsed_mins = int((time.time() - _notify_start) // 60)
# Include agent activity context if available. Default
# heartbeat is terse: elapsed + current tool. Verbose
Expand All @@ -18673,15 +18761,19 @@ async def _notify_long_running():
)
if _agent_ref and hasattr(_agent_ref, "get_activity_summary"):
try:
_a = _agent_ref.get_activity_summary()
_act = _agent_ref.get_activity_summary()
_parts = []
if _want_iteration_detail:
_parts.append(
f"iteration {_a['api_call_count']}/{_a['max_iterations']}"
)
_action = _a.get("current_tool") or _a.get("last_activity_desc")
if _action:
_parts.append(str(_action))
_iter_n = _act.get("api_call_count", 0)
_iter_max = _act.get("max_iterations", 0)
_parts.append(f"iteration {_iter_n}/{_iter_max}")
_cur_tool = _act.get("current_tool")
if _cur_tool:
_parts.append(f"running: {_cur_tool}")
else:
_last_desc = _act.get("last_activity_desc")
if _last_desc:
_parts.append(str(_last_desc))
if _parts:
_status_detail = " — " + ", ".join(_parts)
except Exception:
Expand Down Expand Up @@ -18714,7 +18806,8 @@ async def _notify_long_running():
except Exception as _ne:
logger.debug("Long-running notification error: %s", _ne)

_notify_task = asyncio.create_task(_notify_long_running())
_notify_task = asyncio.create_task(_notify_long_running()) if _notify_interval is not None else None


try:
# Run in thread pool to not block. Use an *inactivity*-based
Expand Down Expand Up @@ -19130,7 +19223,8 @@ async def _notify_long_running():
if progress_task:
progress_task.cancel()
interrupt_monitor.cancel()
_notify_task.cancel()
if _notify_task:
_notify_task.cancel()

# Wait for stream consumer to finish its final edit
if stream_task:
Expand Down
30 changes: 28 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,8 @@ def _ensure_hermes_home_managed(home: Path):
"interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
"still_working_interval": 600, # Seconds between long-running task heartbeats in the gateway (0/false/off disables)
"still_working_overrides": {}, # Per-platform interval overrides: {"signal": "off", "telegram": 300}
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
# Auto-delete system-notice replies (e.g. "✨ New session started!",
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
Expand Down Expand Up @@ -4452,9 +4454,30 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")

# ── Version 16 → 17: remove legacy compression.summary_* keys ──
# ── Version 16 → 17: add still-working heartbeat display settings and remove
# legacy compression.summary_* keys ──
if current_ver < 17:
config = read_raw_config()
changed = False

display = config.get("display", {})
if not isinstance(display, dict):
display = {}
if "still_working_interval" not in display:
display["still_working_interval"] = 600
results["config_added"].append("display.still_working_interval=600 (default)")
changed = True
if not quiet:
print(" ✓ Added display.still_working_interval=600")
if "still_working_overrides" not in display:
display["still_working_overrides"] = {}
results["config_added"].append("display.still_working_overrides={} (default)")
changed = True
if not quiet:
print(" ✓ Added display.still_working_overrides={}")
if changed:
config["display"] = display

comp = config.get("compression", {})
if isinstance(comp, dict):
s_model = comp.pop("summary_model", None)
Expand Down Expand Up @@ -4482,13 +4505,16 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
migrated_keys.append(f"base_url={s_base_url}")
if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None:
config["compression"] = comp
save_config(config)
changed = True
if not quiet:
if migrated_keys:
print(f" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}")
else:
print(" ✓ Removed unused compression.summary_* keys")

if changed:
save_config(config)

# ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
# The loader now requires plugins to appear in ``plugins.enabled`` before
# loading. Existing installs had all discovered plugins loading by default
Expand Down
Loading