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)