From 0325e18f3426b91d0213cc064cbe7355bf028690 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 27 May 2026 05:21:53 -0700 Subject: [PATCH] fix(gateway): keep Telegram heartbeat + interim commentary on; edit heartbeat in place (#33187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #33151 flipped THREE Telegram display defaults to false: - tool_progress: new -> off (kept: per-tool stream is too chatty) - interim_assistant_messages: T -> F (REVERTED here) - long_running_notifications: T -> F (REVERTED here) - busy_ack_detail: T -> F (kept: verbose iteration counter) The two reverts were wrong. interim_assistant_messages = the model's REAL words mid-turn ("I'll inspect the repo first.", "Let me check both files in parallel"). That is signal, not noise. Suppressing it left Telegram users staring at "typing..." for the entire turn duration with no feedback. long_running_notifications = the periodic heartbeat. Silent agent for 30 minutes is worse than one bubble updating every 3 minutes. Changes: - gateway/display_config.py: Telegram tier-1 inbox keeps both defaults on (only tool_progress and busy_ack_detail stay off). - gateway/run.py _notify_long_running(): edit a single heartbeat message in place (where the adapter supports it) instead of posting a new "Still working..." bubble each interval. Telegram, Discord, Slack, Matrix all qualify. Falls back to send-new when edit fails. - gateway/run.py: tighten heartbeat text. "⏳ Still working... (12 min elapsed — iteration 21/60, running: terminal)" -> "⏳ Working — 12 min, terminal". Verbose iteration detail moves behind busy_ack_detail (one knob now controls both busy acks AND heartbeat verbosity). - tests/, cli-config.yaml.example, website/docs/user-guide/messaging: updated to reflect the corrected story. --- cli-config.yaml.example | 26 +++++--- gateway/display_config.py | 14 +++-- gateway/run.py | 72 ++++++++++++++++------ tests/gateway/test_display_config.py | 28 ++++++--- tests/gateway/test_run_cleanup_progress.py | 2 +- tests/gateway/test_run_progress_topics.py | 5 +- website/docs/user-guide/messaging/index.md | 16 ++++- 7 files changed, 114 insertions(+), 49 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 303bdb5c13b..355b6bb7569 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -917,9 +917,13 @@ display: tool_progress: all # Per-platform defaults can be quieter than the global setting. Telegram - # defaults to final-answer-first on mobile: tool progress, interim assistant - # updates, long-running "Still working..." heartbeats, and detailed busy acks - # are off unless re-enabled under display.platforms.telegram. + # tunes for mobile: tool_progress and busy_ack_detail default off (no + # per-tool breadcrumb stream, no "iteration 21/60" debug detail in busy + # acks or heartbeats), but interim_assistant_messages and + # long_running_notifications STAY ON so the user has real signal between + # turn start and final answer (mid-turn assistant commentary + a single + # edit-in-place "⏳ Working — N min" heartbeat). Override under + # display.platforms.telegram. # Auto-cleanup of temporary progress bubbles after the final response lands. # On platforms that support message deletion (currently Telegram), this @@ -945,13 +949,19 @@ display: interim_assistant_messages: true # Gateway-only long-running status heartbeats. - # When false, the platform does not receive periodic "Still working..." - # notifications even if agent.gateway_notify_interval is non-zero. - # Telegram default: false. Other high-capability chat platforms default true. + # When false, the platform does not receive periodic "⏳ Working — N min" + # notifications even if agent.gateway_notify_interval is non-zero. The + # heartbeat edits a single message in place (where the adapter supports + # editing) instead of posting a new bubble each interval. + # Default: true everywhere, including Telegram (silent agents are worse + # than a single edit-in-place heartbeat). long_running_notifications: true - # Include detailed iteration/tool/status context in busy acknowledgments when - # a new user message arrives while a run is active. Telegram default: false. + # Include detailed iteration/tool/status context in busy acknowledgments + # and long-running heartbeats. When true, busy acks show "iteration 21/60, + # terminal, 10 min" and the heartbeat shows "⏳ Working — 12 min, + # iteration 21/60, terminal". When false (Telegram default), both stay + # terse: "Interrupting current task" and "⏳ Working — 12 min, terminal". busy_ack_detail: true # What Enter does when Hermes is already busy (CLI and gateway platforms). diff --git a/gateway/display_config.py b/gateway/display_config.py index e8998b55795..6286ade2be7 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -40,7 +40,7 @@ _GLOBAL_DEFAULTS: dict[str, Any] = { "interim_assistant_messages": True, "long_running_notifications": True, "busy_ack_detail": True, - # When true, delete tool-progress / "Still working..." / status bubbles + # 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 # live, just cleaned up after success so the chat doesn't fill up with @@ -98,14 +98,16 @@ _TIER_MINIMAL = { _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = { # Tier 1 — full edit support, personal/team use - # Telegram is usually a mobile inbox: default to final-answer-first and - # avoid permanent operational breadcrumbs unless users opt back in with - # display.platforms.telegram.tool_progress / long_running_notifications. + # Telegram is usually a mobile inbox: keep tool_progress quiet and skip + # the verbose busy-ack iteration counter, but DO surface real mid-turn + # assistant commentary (interim_assistant_messages) and DO send periodic + # heartbeats (long_running_notifications) so the user has signal between + # turn start and final answer. Otherwise it looks like "typing..." for + # 30 minutes with nothing happening. Opt in to verbose iteration detail + # via display.platforms.telegram.busy_ack_detail / tool_progress. "telegram": { **_TIER_HIGH, "tool_progress": "off", - "interim_assistant_messages": False, - "long_running_notifications": False, "busy_ack_detail": False, }, "discord": _TIER_HIGH, diff --git a/gateway/run.py b/gateway/run.py index cab47854366..3e23cf2352a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15914,7 +15914,7 @@ class GatewayRunner: # Auto-cleanup of temporary progress bubbles (Telegram + any adapter # that implements ``delete_message``). When enabled via # ``display.platforms..cleanup_progress: true``, message IDs - # from the tool-progress / "Still working..." / status-callback bubbles + # from the tool-progress / "⏳ Working — N min" / status-callback bubbles # are collected here and deleted after the final response lands. # Failed runs skip cleanup so the bubbles remain as breadcrumbs. _cleanup_progress = bool( @@ -17455,35 +17455,69 @@ class GatewayRunner: _notify_adapter = self.adapters.get(source.platform) if not _notify_adapter: return + # Track the heartbeat message id so we can edit-in-place on + # platforms that support it (Telegram, Discord, Slack, etc.) + # instead of spamming a new "Still working" bubble every + # interval. Falls back to send-new when edit fails or isn't + # supported by the adapter. + _heartbeat_msg_id: Optional[str] = None while True: await asyncio.sleep(_NOTIFY_INTERVAL) _elapsed_mins = int((time.time() - _notify_start) // 60) - # Include agent activity context if available. + # Include agent activity context if available. Default + # heartbeat is terse: elapsed + current tool. Verbose + # iteration counter is gated on busy_ack_detail so users + # who want it can opt in per platform. _agent_ref = agent_holder[0] _status_detail = "" + _want_iteration_detail = bool( + resolve_display_setting( + user_config, + platform_key, + "busy_ack_detail", + True, + ) + ) if _agent_ref and hasattr(_agent_ref, "get_activity_summary"): try: _a = _agent_ref.get_activity_summary() - _parts = [f"iteration {_a['api_call_count']}/{_a['max_iterations']}"] - if _a.get("current_tool"): - _parts.append(f"running: {_a['current_tool']}") - else: - _parts.append(_a.get("last_activity_desc", "")) - _status_detail = " — " + ", ".join(_parts) + _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)) + if _parts: + _status_detail = " — " + ", ".join(_parts) except Exception: pass + _heartbeat_text = f"⏳ Working — {_elapsed_mins} min{_status_detail}" try: - _notify_res = await _notify_adapter.send( - source.chat_id, - f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})", - metadata=_status_thread_metadata, - ) - if ( - _cleanup_progress - and getattr(_notify_res, "success", False) - and getattr(_notify_res, "message_id", None) - ): - _cleanup_msg_ids.append(str(_notify_res.message_id)) + _notify_res = None + if _heartbeat_msg_id: + try: + _notify_res = await _notify_adapter.edit_message( + source.chat_id, + _heartbeat_msg_id, + _heartbeat_text, + ) + except Exception as _ee: + logger.debug("Heartbeat edit failed: %s", _ee) + _notify_res = None + if not (_notify_res and getattr(_notify_res, "success", False)): + _notify_res = await _notify_adapter.send( + source.chat_id, + _heartbeat_text, + metadata=_status_thread_metadata, + ) + if getattr(_notify_res, "success", False) and getattr( + _notify_res, "message_id", None + ): + _heartbeat_msg_id = str(_notify_res.message_id) + if _cleanup_progress: + _cleanup_msg_ids.append(_heartbeat_msg_id) except Exception as _ne: logger.debug("Long-running notification error: %s", _ne) diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 06c28debdb3..5f23edbd4f5 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -228,34 +228,44 @@ class TestPlatformDefaults: assert resolve_display_setting({}, "telegram", "streaming") is None - def test_telegram_mobile_chatter_defaults_off(self): - """Telegram suppresses operational chat noise unless opted in.""" + def test_telegram_mobile_chatter_defaults(self): + """Telegram keeps real mid-turn signal (interim commentary + heartbeats) + but skips the verbose busy-ack iteration counter by default.""" from gateway.display_config import resolve_display_setting - assert resolve_display_setting({}, "telegram", "interim_assistant_messages") is False - assert resolve_display_setting({}, "telegram", "long_running_notifications") is False + # Real model voice — keep on. Without this, Telegram users see + # "typing..." for the entire turn duration with no feedback. + assert resolve_display_setting({}, "telegram", "interim_assistant_messages") is True + # Periodic "Working — N min" heartbeat — keep on. Otherwise long + # turns appear completely silent. + assert resolve_display_setting({}, "telegram", "long_running_notifications") is True + # Verbose iteration counter in busy-ack and heartbeat — off by + # default on Telegram (mobile chat is cramped enough without + # "iteration 21/60" debug detail). assert resolve_display_setting({}, "telegram", "busy_ack_detail") is False + # Discord keeps all of these on (desktop-first, more vertical space). assert resolve_display_setting({}, "discord", "interim_assistant_messages") is True assert resolve_display_setting({}, "discord", "long_running_notifications") is True assert resolve_display_setting({}, "discord", "busy_ack_detail") is True def test_telegram_mobile_chatter_can_opt_in(self): - """Per-platform config can re-enable Telegram status chatter.""" + """Per-platform config can re-enable Telegram busy-ack detail + and re-disable the kept-on defaults.""" from gateway.display_config import resolve_display_setting config = { "display": { "platforms": { "telegram": { - "interim_assistant_messages": True, - "long_running_notifications": "yes", + "interim_assistant_messages": False, + "long_running_notifications": False, "busy_ack_detail": "on", } } } } - assert resolve_display_setting(config, "telegram", "interim_assistant_messages") is True - assert resolve_display_setting(config, "telegram", "long_running_notifications") is True + assert resolve_display_setting(config, "telegram", "interim_assistant_messages") is False + assert resolve_display_setting(config, "telegram", "long_running_notifications") is False assert resolve_display_setting(config, "telegram", "busy_ack_detail") is True diff --git a/tests/gateway/test_run_cleanup_progress.py b/tests/gateway/test_run_cleanup_progress.py index 3e1439cc0df..dfb5ef03342 100644 --- a/tests/gateway/test_run_cleanup_progress.py +++ b/tests/gateway/test_run_cleanup_progress.py @@ -2,7 +2,7 @@ When ``display.platforms..cleanup_progress: true`` is set for a platform whose adapter supports message deletion (e.g. Telegram), the -tool-progress bubble, "⏳ Still working..." notices, and status-callback +tool-progress bubble, "⏳ Working — N min" heartbeats, and status-callback messages sent during a run are deleted after the final response is delivered. diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 09a6030226c..5b7dfb821b0 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -784,13 +784,12 @@ async def test_run_agent_surfaces_real_interim_commentary(monkeypatch, tmp_path) @pytest.mark.asyncio -async def test_run_agent_surfaces_interim_commentary_when_globally_enabled(monkeypatch, tmp_path): +async def test_run_agent_surfaces_interim_commentary_by_default(monkeypatch, tmp_path): adapter, result = await _run_with_agent( monkeypatch, tmp_path, CommentaryAgent, - session_id="sess-commentary-global-on", - config_data={"display": {"interim_assistant_messages": True}}, + session_id="sess-commentary-default-on", ) assert any(call["content"] == "I'll inspect the repo first." for call in adapter.sent) diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index a2c29c700fa..b1cc6232525 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -509,16 +509,26 @@ No configuration is required. If you don't want the heads-up, set `gateway_resta ### Mobile-friendly progress defaults -Telegram defaults to final-answer-first output: no tool-progress stream, no periodic "still working…" heartbeat, no interim assistant status messages, and concise busy acknowledgments. Opt back into any of those per platform: +Telegram is usually a mobile inbox, so the defaults are tuned for that surface: + +- **`tool_progress`** defaults to **`off`** — no per-tool breadcrumb stream filling up the chat. +- **`busy_ack_detail`** defaults to **`off`** — busy-state acknowledgments and long-running heartbeats stay terse (no `iteration 21/60` debug detail). +- **`interim_assistant_messages`** stays **on** — real mid-turn assistant commentary (the model literally telling you what it's about to do) is signal, not noise. +- **`long_running_notifications`** stays **on** — a single edit-in-place "⏳ Working — N min" bubble updates every few minutes so you have a heartbeat instead of staring at `typing…` for half an hour. + +Opt out of either of the kept-on defaults or opt back into verbose progress per platform: ```yaml display: platforms: telegram: + # Re-enable the tool-progress stream tool_progress: new - interim_assistant_messages: true - long_running_notifications: true + # Show "iteration N/M, running: tool" in heartbeats and busy acks busy_ack_detail: true + # Or quiet them entirely + interim_assistant_messages: false + long_running_notifications: false ``` ### Progress bubble cleanup (opt-in)