mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(gateway): opt-in cleanup of temporary progress bubbles (#21186)
When display.cleanup_progress (or display.platforms.<plat>.cleanup_progress)
is true, the gateway deletes tool-progress bubbles, long-running '⏳ Still
working...' notices, and status-callback messages after the final response
is delivered successfully. Currently effective on adapters that implement
delete_message (Telegram); silently no-ops elsewhere. Off by default.
Failed runs skip cleanup so bubbles stay as breadcrumbs.
Minimal plumbing: base.py's existing post_delivery_callback slot now chains
new registrations onto any existing callback (with per-callback exception
isolation) rather than clobbering. Stale-generation registrations are
rejected so they can't step on a fresher run's callbacks. This lets the
cleanup callback coexist with the background-review release hook already
registered on the same slot.
Co-authored-by: mrcharlesiv <Mrcharlesiv@gmail.com>
This commit is contained in:
parent
7c0766e06a
commit
bf843adf05
8 changed files with 700 additions and 4 deletions
|
|
@ -35,6 +35,12 @@ _GLOBAL_DEFAULTS: dict[str, Any] = {
|
|||
"show_reasoning": False,
|
||||
"tool_preview_length": 0,
|
||||
"streaming": None, # None = follow top-level streaming config
|
||||
# When true, delete tool-progress / "Still working..." / 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
|
||||
# stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs.
|
||||
"cleanup_progress": False,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -188,6 +194,10 @@ def _normalise(setting: str, value: Any) -> Any:
|
|||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
if setting == "cleanup_progress":
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
if setting == "tool_preview_length":
|
||||
try:
|
||||
return int(value)
|
||||
|
|
|
|||
|
|
@ -2096,9 +2096,52 @@ class BasePlatformAdapter(ABC):
|
|||
|
||||
``generation`` lets callers tie the callback to a specific gateway run
|
||||
generation so stale runs cannot clear callbacks owned by a fresher run.
|
||||
|
||||
If a callback for the same ``session_key`` (and generation, when set)
|
||||
is already registered, the new callback is chained — both fire, in
|
||||
registration order, with per-callback exception isolation. This lets
|
||||
independent features (background-review release + temporary-bubble
|
||||
cleanup) coexist without clobbering each other. Stale-generation
|
||||
callers never overwrite a fresher generation's slot.
|
||||
"""
|
||||
if not session_key or not callable(callback):
|
||||
return
|
||||
|
||||
existing = self._post_delivery_callbacks.get(session_key)
|
||||
if existing is not None:
|
||||
if isinstance(existing, tuple) and len(existing) == 2:
|
||||
existing_gen, existing_cb = existing
|
||||
else:
|
||||
existing_gen, existing_cb = None, existing
|
||||
# Stale-generation registrations never overwrite a fresher slot.
|
||||
if (
|
||||
existing_gen is not None
|
||||
and generation is not None
|
||||
and int(generation) < int(existing_gen)
|
||||
):
|
||||
return
|
||||
# Same-or-newer generation: chain with the existing callback so
|
||||
# both fire in registration order.
|
||||
if callable(existing_cb) and (
|
||||
existing_gen is None
|
||||
or generation is None
|
||||
or int(existing_gen) == int(generation)
|
||||
):
|
||||
_prev = existing_cb
|
||||
_new = callback
|
||||
|
||||
def _chained() -> None:
|
||||
try:
|
||||
_prev()
|
||||
except Exception:
|
||||
logger.debug("Post-delivery callback failed", exc_info=True)
|
||||
try:
|
||||
_new()
|
||||
except Exception:
|
||||
logger.debug("Post-delivery callback failed", exc_info=True)
|
||||
|
||||
callback = _chained
|
||||
|
||||
if generation is None:
|
||||
self._post_delivery_callbacks[session_key] = callback
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -12845,6 +12845,24 @@ class GatewayRunner:
|
|||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
|
||||
# that implements ``delete_message``). When enabled via
|
||||
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
|
||||
# from the tool-progress / "Still working..." / 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(
|
||||
resolve_display_setting(user_config, platform_key, "cleanup_progress")
|
||||
)
|
||||
_cleanup_adapter = self.adapters.get(source.platform) if _cleanup_progress else None
|
||||
if _cleanup_adapter is not None and (
|
||||
type(_cleanup_adapter).delete_message is BasePlatformAdapter.delete_message
|
||||
):
|
||||
# Adapter doesn't support deletion — silently disable.
|
||||
_cleanup_progress = False
|
||||
_cleanup_adapter = None
|
||||
_cleanup_msg_ids: List[str] = []
|
||||
# First-touch onboarding latch: fires at most once per run, even if
|
||||
# several tools exceed the threshold.
|
||||
long_tool_hint_fired = [False]
|
||||
|
|
@ -13093,12 +13111,18 @@ class GatewayRunner:
|
|||
adapter.name,
|
||||
)
|
||||
can_edit = False
|
||||
await adapter.send(
|
||||
_flood_result = await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=msg,
|
||||
reply_to=_progress_reply_to,
|
||||
metadata=_progress_metadata,
|
||||
)
|
||||
if (
|
||||
_cleanup_progress
|
||||
and getattr(_flood_result, "success", False)
|
||||
and getattr(_flood_result, "message_id", None)
|
||||
):
|
||||
_cleanup_msg_ids.append(str(_flood_result.message_id))
|
||||
else:
|
||||
if can_edit:
|
||||
# First tool: send all accumulated text as new message
|
||||
|
|
@ -13119,6 +13143,8 @@ class GatewayRunner:
|
|||
)
|
||||
if result.success and result.message_id:
|
||||
progress_msg_id = result.message_id
|
||||
if _cleanup_progress:
|
||||
_cleanup_msg_ids.append(str(result.message_id))
|
||||
|
||||
_last_edit_ts = time.monotonic()
|
||||
|
||||
|
|
@ -13232,7 +13258,7 @@ class GatewayRunner:
|
|||
if not _status_adapter or not _run_still_current():
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_fut = asyncio.run_coroutine_threadsafe(
|
||||
_status_adapter.send(
|
||||
_status_chat_id,
|
||||
message,
|
||||
|
|
@ -13240,6 +13266,16 @@ class GatewayRunner:
|
|||
),
|
||||
_loop_for_step,
|
||||
)
|
||||
if _cleanup_progress:
|
||||
def _track_status_id(fut) -> None:
|
||||
try:
|
||||
res = fut.result()
|
||||
except Exception:
|
||||
return
|
||||
mid = getattr(res, "message_id", None)
|
||||
if getattr(res, "success", False) and mid:
|
||||
_cleanup_msg_ids.append(str(mid))
|
||||
_fut.add_done_callback(_track_status_id)
|
||||
except Exception as _e:
|
||||
logger.debug("status_callback error (%s): %s", event_type, _e)
|
||||
|
||||
|
|
@ -14100,11 +14136,17 @@ class GatewayRunner:
|
|||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await _notify_adapter.send(
|
||||
_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))
|
||||
except Exception as _ne:
|
||||
logger.debug("Long-running notification error: %s", _ne)
|
||||
|
||||
|
|
@ -14578,7 +14620,49 @@ class GatewayRunner:
|
|||
_previewed,
|
||||
)
|
||||
response["already_sent"] = True
|
||||
|
||||
|
||||
# Schedule deletion of tracked temporary progress bubbles after the
|
||||
# final response lands. Failed runs skip this so bubbles remain as
|
||||
# breadcrumbs for the user to see what work happened. Only fires on
|
||||
# adapters that support ``delete_message`` (see init above); failures
|
||||
# are swallowed — deletion is best-effort.
|
||||
if (
|
||||
_cleanup_progress
|
||||
and _cleanup_adapter is not None
|
||||
and _cleanup_msg_ids
|
||||
and session_key
|
||||
and isinstance(response, dict)
|
||||
and not response.get("failed")
|
||||
and hasattr(_cleanup_adapter, "register_post_delivery_callback")
|
||||
):
|
||||
_ids_snapshot = list(_cleanup_msg_ids)
|
||||
_chat_id_snapshot = source.chat_id
|
||||
_adapter_snapshot = _cleanup_adapter
|
||||
_loop_snapshot = asyncio.get_running_loop()
|
||||
|
||||
def _cleanup_temp_bubbles() -> None:
|
||||
async def _delete_all() -> None:
|
||||
for _mid in _ids_snapshot:
|
||||
try:
|
||||
await _adapter_snapshot.delete_message(
|
||||
_chat_id_snapshot, _mid
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(_delete_all(), _loop_snapshot)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_cleanup_adapter.register_post_delivery_callback(
|
||||
session_key,
|
||||
_cleanup_temp_bubbles,
|
||||
generation=run_generation,
|
||||
)
|
||||
except Exception as _rpe:
|
||||
logger.debug("Post-delivery cleanup registration failed: %s", _rpe)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue