Bug Description
After a tool call completes in the Telegram gateway, the LLM's post-tool streaming response is sometimes truncated mid-sentence and frozen with a block cursor (▉). The remainder of the response is silently dropped and never delivered.
Confirmed reproduced with a deterministic unit test against the real GatewayStreamConsumer.
Steps to Reproduce
- Run Hermes gateway connected to Telegram
- Send a message that triggers a tool call (e.g.
skill_view)
- Observe the streaming response after the tool result
- If the final Telegram edit (cursor removal) fails due to flood control or a transient rejection, the message freezes with ▉ and no further content is delivered
Expected Behavior
The full response is delivered. If the final cursor-removal edit fails, the cursor should be cleared and/or the complete text sent as a fallback.
Actual Behavior
The partial message (e.g. 📚 skill_view: "codexbar"\n\nThe ▉) is left frozen permanently. This is most visible with short post-tool responses.
Affected Component
- Gateway (Telegram/Discord/Slack/WhatsApp)
Messaging Platform
Operating System
Ubuntu 24.04 (aarch64 — NVIDIA DGX Spark)
Python Version
3.11.15
Hermes Version
v0.8.0 (2026.4.8)
Relevant Logs / Traceback
No explicit traceback — the failure is silent. The final edit_message_text call returns success=False (flood control or transient rejection), triggering the fallback path which silently no-ops.
Root Cause Analysis
Exact failure sequence (reproduced):
SEND 1: 'T ▉'
EDIT 1: 'Th ▉' ← OK
EDIT 2: 'The ▉' ← OK
EDIT 3: 'The ▉' ← OK
EDIT 4: 'The ' ← FAILS (flood control / transient rejection)
_last_sent_text = 'The ▉' ← frozen permanently
Three interlocking issues in gateway/stream_consumer.py:
1. Edit failure arms fallback (stream_consumer.py:332-340)
When any edit_message() returns success=False, the consumer saves _fallback_prefix (the visible text without cursor) and sets _fallback_final_send=True, _edit_supported=False, _already_sent=True.
2. _send_fallback_final() computes empty continuation (stream_consumer.py:260-268)
At stream finish, fallback computes continuation = final_text[len(_fallback_prefix):]. For a short response like "The ", _fallback_prefix is already "The ", so continuation == "". The early-return guard fires, sets already_sent=True, and returns without sending anything or clearing the cursor.
3. Gateway final send is suppressed (gateway/run.py:7359)
Because already_sent=True, the normal gateway send path is skipped. The frozen ▉ is the permanent final state.
Why short responses are affected but long ones aren't:
A short response fits in one intermediate edit cycle. The only remaining change at finish is cursor removal — so continuation is empty and fallback silently no-ops. Longer responses have unseen tail text, so fallback sends that as a new message (bug still fires internally but is masked).
Proposed Fix
Option A (minimal): In _send_fallback_final() (stream_consumer.py:265-268), before returning on empty continuation, do a final edit to strip the cursor if _message_id is set and _last_sent_text ends with the cursor.
Option B (cleaner): In the got_done branch (stream_consumer.py:167-179), always attempt cursor removal as a dedicated step before invoking fallback logic. If that edit fails, send the full accumulated text as a new message rather than computing a potentially-empty continuation.
Repro Script
import asyncio, sys
sys.path.insert(0, "/path/to/hermes-agent")
from gateway.platforms.base import SendResult
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
class MockAdapter:
MAX_MESSAGE_LENGTH = 4096
def __init__(self): self.send_calls=[]; self.edit_calls=[]; self._ec=0
async def send(self, chat_id, content, metadata=None):
self.send_calls.append(content)
return SendResult(success=True, message_id="msg-1")
async def edit_message(self, chat_id, message_id, content):
self._ec += 1; self.edit_calls.append(content)
if content == "The ": # fail on final cursor-removal edit
return SendResult(success=False, error="flood_control:6.0")
return SendResult(success=True, message_id=message_id)
async def main():
adapter = MockAdapter()
consumer = GatewayStreamConsumer(adapter, "123",
config=StreamConsumerConfig(edit_interval=0.0, buffer_threshold=1, cursor=" ▉"))
task = asyncio.create_task(consumer.run())
consumer.on_delta(None) # _NEW_SEGMENT (tool boundary)
await asyncio.sleep(0.08)
for ch in ["T", "h", "e", " "]:
consumer.on_delta(ch); await asyncio.sleep(0.08)
consumer.finish()
await task
frozen = consumer._last_sent_text.endswith(consumer.cfg.cursor)
print("REPRODUCED" if frozen else "NOT REPRODUCED")
asyncio.run(main())
Are you willing to submit a PR?
Bug Description
After a tool call completes in the Telegram gateway, the LLM's post-tool streaming response is sometimes truncated mid-sentence and frozen with a block cursor (▉). The remainder of the response is silently dropped and never delivered.
Confirmed reproduced with a deterministic unit test against the real
GatewayStreamConsumer.Steps to Reproduce
skill_view)Expected Behavior
The full response is delivered. If the final cursor-removal edit fails, the cursor should be cleared and/or the complete text sent as a fallback.
Actual Behavior
The partial message (e.g.
📚 skill_view: "codexbar"\n\nThe ▉) is left frozen permanently. This is most visible with short post-tool responses.Affected Component
Messaging Platform
Operating System
Ubuntu 24.04 (aarch64 — NVIDIA DGX Spark)
Python Version
3.11.15
Hermes Version
v0.8.0 (2026.4.8)
Relevant Logs / Traceback
No explicit traceback — the failure is silent. The final
edit_message_textcall returnssuccess=False(flood control or transient rejection), triggering the fallback path which silently no-ops.Root Cause Analysis
Exact failure sequence (reproduced):
Three interlocking issues in
gateway/stream_consumer.py:1. Edit failure arms fallback (stream_consumer.py:332-340)
When any
edit_message()returnssuccess=False, the consumer saves_fallback_prefix(the visible text without cursor) and sets_fallback_final_send=True,_edit_supported=False,_already_sent=True.2.
_send_fallback_final()computes empty continuation (stream_consumer.py:260-268)At stream finish, fallback computes
continuation = final_text[len(_fallback_prefix):].For a short response like"The ",_fallback_prefixis already"The ", socontinuation == "". The early-return guard fires, setsalready_sent=True, and returns without sending anything or clearing the cursor.3. Gateway final send is suppressed (gateway/run.py:7359)
Because
already_sent=True, the normal gateway send path is skipped. The frozen▉is the permanent final state.Why short responses are affected but long ones aren't:
A short response fits in one intermediate edit cycle. The only remaining change at finish is cursor removal — so continuation is empty and fallback silently no-ops. Longer responses have unseen tail text, so fallback sends that as a new message (bug still fires internally but is masked).
Proposed Fix
Option A (minimal): In
_send_fallback_final()(stream_consumer.py:265-268), before returning on empty continuation, do a final edit to strip the cursor if_message_idis set and_last_sent_textends with the cursor.Option B (cleaner): In the
got_donebranch (stream_consumer.py:167-179), always attempt cursor removal as a dedicated step before invoking fallback logic. If that edit fails, send the full accumulated text as a new message rather than computing a potentially-empty continuation.Repro Script
Are you willing to submit a PR?