Skip to content

feat(telegram): fire telegram:reaction hook when user reacts to bot messages - #53814

Open
fsaad1984 wants to merge 10 commits into
NousResearch:mainfrom
fsaad1984:feat/telegram-reaction-hook
Open

feat(telegram): fire telegram:reaction hook when user reacts to bot messages#53814
fsaad1984 wants to merge 10 commits into
NousResearch:mainfrom
fsaad1984:feat/telegram-reaction-hook

Conversation

@fsaad1984

Copy link
Copy Markdown

Adds support for Telegram message reaction events on messages sent by the bot.

What changed

  • TelegramAdapter: registers MessageReactionHandler (PTB 22.6+). _handle_reaction() looks up message_id in rich_sent_store (only fires for bot messages), extracts emoji, calls _reaction_callback
  • BasePlatformAdapter: set_reaction_callback() — same pattern as set_message_handler()
  • GatewayRunner: _handle_telegram_reaction() emits telegram:reaction hook event. Wired in all three adapter-setup paths.

Payload

chat_id, message_id, user_id, user_name, new_reactions (list[str]), old_reactions (list[str]), message_text

Notes

  • Zero cost when no hook installed — purely hook-based
  • allowed_updates=Update.ALL_TYPES already set, no polling change needed
  • Requires PTB >= 21.0 (venv has 22.6)

Anthropic returns HTTP 400 when a tool_use block is not immediately
followed by its tool_result.  Two root causes exist:

1. Context compression inserts messages between the pair.
   _strip_orphaned_tool_blocks (PR NousResearch#52145) already fixes the *wire
   payload*, but it mutates api_messages — a shallow copy of the
   canonical messages list.  The canonical list is unchanged, so the
   *next* API call rebuilds the same broken payload and hits the same
   400 again.

2. A cron/subagent session is interrupted before the tool_result is
   appended.  Concrete reproduction: the approval guard blocks
   execute_code inside a cron job (no user present), the tool handler
   returns an error JSON which the tool_executor normally wraps in a
   tool_result message.  But in this case the gateway reloaded the
   session transcript from disk AFTER the interruption, finding
   disk=0 messages vs memory=37.  The live (correct) history was
   preserved, but a prior interrupted turn had left a bare tool_use as
   the last assistant block with no following user/tool_result turn.
   _strip_orphaned_tool_blocks never ran against the canonical list, so
   the next API call sent the broken transcript verbatim.

Fix — three-file change:

* agent/error_classifier.py: new FailoverReason.orphaned_tool_use +
  detection pattern in _classify_400.  The Anthropic error message
  always contains both 'tool_use' and 'tool_result', which is
  distinctive enough for a safe substring match.  retryable=True so
  the retry loop continues rather than aborting.

* agent/turn_retry_state.py: orphaned_tool_use_retry_attempted flag so
  the recovery branch fires at most once per turn (prevents an infinite
  strip-and-retry loop if stripping somehow fails to fix the issue).

* agent/conversation_loop.py: recovery branch that runs
  _strip_orphaned_tool_blocks against the canonical messages list
  (not just the wire payload) so the cleaned transcript is persisted
  and the retry sees a valid conversation.

Reproduction: long gateway session → tool call → execute_code blocked
by cron approval guard → gateway reload from disk finds stale/empty
transcript → live history preserved but contains orphaned tool_use →
HTTP 400 crash-loop.
…nd OpenAI-style canonical messages

The canonical messages list uses OpenAI-style role=tool/tool_calls,
not the Anthropic wire format that _strip_orphaned_tool_blocks expects.
The original fix stripped 0 entries because it passed the wrong list.

Now: (1) detect orphaned IDs from api_messages (Anthropic format),
(2) strip api_messages via _strip_orphaned_tool_blocks, (3) also clean
the canonical messages list by removing orphaned tool_calls entries
and their matching role=tool messages so the next api_messages rebuild
produces a valid transcript.
…ssages

api_messages at error-handler time is pre-conversion; the Anthropic adapter
converts tool_calls→tool_use internally. Detect orphaned IDs from canonical
messages (role=tool / tool_calls) instead of api_messages.
…l messages

The canonical messages pair IS present but adjacency breaks during
Anthropic adapter conversion (context compaction injects synthetic user
messages). Parse the IDs directly from the Anthropic 400 error string.

Also fix: used 'classified_err' (undefined) instead of 'api_error'.
…essages

- Add MessageReactionHandler to TelegramAdapter — registers with PTB 22.6+
- _handle_reaction() looks up message via rich_sent_store to confirm it's
  a bot message, then fires the _reaction_callback with a structured payload
  (chat_id, message_id, user_id, user_name, new/old_reactions, message_text)
- Add set_reaction_callback() to BasePlatformAdapter so the runner can
  install the callback without knowing the concrete adapter type
- GatewayRunner._handle_telegram_reaction() emits telegram:reaction hook
  event via self.hooks, wired up in all three adapter-setup paths
- All three adapter-setup paths in run.py updated (initial connect,
  reconnect loop, multi-profile path)

The hook fires only for reactions on messages the bot sent (verified via
rich_sent_store lookup), ignoring reactions on user messages entirely.
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages P3 Low — cosmetic, nice to have labels Jun 27, 2026
…re API call

When context compression fires, the newly-created compaction message
(role=assistant, content starting with '[CONTEXT COMPACTION — REFERENCE ONLY]')
can inherit the tool_calls from the last assistant turn that was archived.
Those tool_calls have no matching tool results in the active message list
(the results were soft-archived with the pre-compaction transcript), causing
Anthropic HTTP 400: 'tool_use ids found without tool_result blocks immediately after'.

The existing runtime-recovery path (_orphaned_tool_use_retry) catches this and
strips the IDs — but only after already exhausting 3 retries, and only for the
outermost orphan set. When multiple compaction layers accumulate, the one-shot
retry still fails on the second set of orphaned IDs.

Fix: in sanitize_api_messages() (which runs before every API call), detect
assistant messages whose text content is a compaction summary and strip any
tool_calls from them proactively. This prevents the 400 entirely without
touching the stored conversation history.
The strip_orphaned_tool_blocks function checks result[i+1] for adjacency,
but was running before _merge_consecutive_roles. This meant that two
consecutive user messages (one plain, one with tool_result) would falsely
look non-adjacent, causing valid tool_use blocks to be stripped.

Fix: swap the call order — merge first, then strip orphans.

Add two regression tests:
- test_strips_non_adjacent_tool_use: verifies that a tool_use whose
  tool_result is separated by an intervening assistant turn is stripped
- test_consecutive_user_messages_merged_before_adjacency_check: verifies
  that a valid pair is preserved when merge makes the result adjacent

Addresses reviewer feedback on PR NousResearch#52145.
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for adding a narrow PTB reaction entry point; current main does not register MessageReactionHandler in plugins/platforms/telegram/adapter.py:3186-3203, so the capability is still useful.

Problems

  • The new reaction path calls the callback directly, bypassing the gateway authorization gate in gateway/run.py:8946-8960. BasePlatformAdapter._is_sender_authorized() already provides the adapter-side seam at gateway/platforms/base.py:2852-2874; Signal explicitly guards reaction paths for this reason in gateway/platforms/signal.py:276-283.
  • The changed tests cover only Anthropic orphaned-tool-use recovery. There is no test for Telegram handler registration, bot-message filtering, emitted payload, hook dispatch, or unauthorized reactors.
  • This feature PR also includes unrelated agent recovery changes and Dutch busy-message text in gateway/run.py; please keep the reaction salvage focused.

Suggested changes

  • Authorize the reacting user before emitting the hook, then add focused mocked PTB/hook tests for allowed and denied reactions.
  • Split the unrelated agent and text changes; document the new event and payload in the gateway-hook reference.

Automated hermes-sweeper review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/telegram Telegram bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants