Bug Description
When context compression triggers a session split during a Telegram DM topic conversation, the agent's final response is delivered to the "All Messages" (root) thread instead of the active DM topic thread.
This is a related but distinct issue from #20470 (which only fixed topic→session binding persistence).
Root-Cause Analysis
- During a turn,
run_agent.py mid-turn compression may create a new child session (agent.session_id changes).
gateway/run.py detects this at Session split detected: old_id → new_id (compression) and updates session_entry.session_id.
- Bug: the response is sent using the original inbound
event.source for thread metadata (_reply_anchor_for_event(event), _thread_metadata_for_source(event.source, …)).
If that inbound event had thread_id=None — which happens for synthetic / recovered events, or if the original source object lost its thread_id during the split — the Telegram adapter receives no message_thread_id.
- Telegram adapter defaults to
message_thread_id=None → message lands in the General ("All Messages") topic.
Code Path
# gateway/run.py ~line 7705
agent_result = await self._run_agent(
...
source=source, # ← original inbound source
session_id=session_entry.session_id,
...
)
# ~line 7760
logger.info("response ready: platform=%s chat=%s ...", ...)
# ~line 7900+ (inside _handle_message_with_agent → _process_turn)
# Response text is returned up-stack, eventually passed to:
# adapter.send(chat_id, content=response, reply_to=..., metadata=thread_meta)
# where thread_meta comes from:
# _thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))
# If event.source.thread_id is None → metadata has no direct_messages_topic_id → All Messages
Affected Scenarios
- Synthetic messages injected during a long-running task before compression fires (e.g. "⏳ Still working..." delivered correctly to topic, but the final response after split goes to All Messages).
- Any tool execution that crosses the compression boundary during a DM topic session.
- Session resume after gateway restart where the resumed event loses thread context.
Proposed Fix
After a session split is detected, recover the active Telegram DM topic thread ID from the SessionDB binding and inject it into the response metadata before calling adapter.send.
# In gateway/run.py, inside the session-split block (~line 15817):
if _session_was_split and self._is_telegram_topic_lane(source):
# Ensure the response metadata routes back to the correct DM topic.
# If source.thread_id was lost, look it up from the binding we just refreshed.
if source.thread_id is None:
binding = self._session_db.get_telegram_topic_binding(
chat_id=str(source.chat_id),
thread_id=None, # lookup by session_key instead
)
if binding:
source.thread_id = binding.thread_id
A cleaner alternative: persist the event.source (or at least its thread_id) before _run_agent begins, and restore it after split.
Environment
- Hermes version: v0.14.0
- Platform: Telegram (DM topics)
- macOS 15.6.1
Related Issues
Bug Description
When context compression triggers a session split during a Telegram DM topic conversation, the agent's final response is delivered to the "All Messages" (root) thread instead of the active DM topic thread.
This is a related but distinct issue from #20470 (which only fixed topic→session binding persistence).
Root-Cause Analysis
run_agent.pymid-turn compression may create a new child session (agent.session_idchanges).gateway/run.pydetects this atSession split detected: old_id → new_id (compression)and updatessession_entry.session_id.event.sourcefor thread metadata (_reply_anchor_for_event(event),_thread_metadata_for_source(event.source, …)).If that inbound event had
thread_id=None— which happens for synthetic / recovered events, or if the original source object lost itsthread_idduring the split — the Telegram adapter receives nomessage_thread_id.message_thread_id=None→ message lands in the General ("All Messages") topic.Code Path
Affected Scenarios
Proposed Fix
After a session split is detected, recover the active Telegram DM topic thread ID from the SessionDB binding and inject it into the response metadata before calling
adapter.send.A cleaner alternative: persist the
event.source(or at least itsthread_id) before_run_agentbegins, and restore it after split.Environment
Related Issues