feat(telegram): guest-mode native media delivery + lazy two-phase stub - #52639
Closed
elphamale wants to merge 14 commits into
Closed
feat(telegram): guest-mode native media delivery + lazy two-phase stub#52639elphamale wants to merge 14 commits into
elphamale wants to merge 14 commits into
Conversation
…validation When the terminal backend is Docker, agent commands run inside a container where /workspace is a bind-mount of a host directory. send_message runs on the HOST where /workspace doesn't exist, so validate_media_delivery_path silently drops MEDIA:/workspace/... files — the text caption is sent but the video/image never is. Add _translate_docker_workspace_paths() which looks up the active DockerEnvironment._workspace_dir from terminal_tool._active_environments and rewrites /workspace/... paths to their host equivalents before path validation runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…+ /tmp fallback
The previous fix only handled /workspace/ paths. Agent downloads often land
in /tmp/ or /root/.hermes/cache/ inside the container, which are not bind-
mounted to /workspace but may be covered by other mounts (e.g. audio_cache).
New approach:
1. Run `docker inspect` to get the full mount table for the active container.
2. Walk mounts longest-first to translate any container path to its host
equivalent (covers /root, /root/.hermes/cache/audio, /mnt/hermes_home, etc.)
3. For paths in unmounted dirs like /tmp/, fall back to `docker cp` into a
temp file on the host so the file can still be delivered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upstream commit 5600105 moved gateway/platforms/telegram.py → plugins/platforms/telegram/adapter.py. This applies the equivalent of PRs NousResearch#43049 / NousResearch#49116 / NousResearch#49186 to the new path: - NousResearch#43049 (guest mode): _pending_guest_queries / _guest_only_chats / _guest_reply_buffer state; send() buffer block; TypeHandler registration; _handle_guest_message_update(); on_processing_complete() answerGuestQuery flush; media-method guards for send_voice / send_image_file / send_document / send_video / send_image. - NousResearch#49116 (sender_chat): _build_message_event() uses sender_chat.id / .title when from_user is None (channel-as-user posts in groups). - NousResearch#49186 (buffer quality): tool-progress drops (expect_edits/notify flags), cursor-strip before buffering, startswith-accumulation so cumulative streaming frames replace rather than double-append. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PTB 22.6's Update.ALL_TYPES does not include guest_message (Bot API 10.0 field not yet in the typed layer). Telegram only delivers an update type if it is listed in allowed_updates — without this, getUpdates never returns guest_message payloads and guest mode is silently dead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The _reconnect_polling path had a different indentation for its allowed_updates call so the previous replace_all missed it. This ensures guest_message is requested even after a network-error reconnect. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eamed final reply In draft-streaming mode (Telegram DMs), _message_id stays None throughout streaming. At got_done the stream consumer reaches the "First message" path and calls adapter.send() with a full-length response. When that response exceeds 4096 chars the legacy split path sends M1 "(1/2)" + M2 "(2/2)" but returned only message_ids[0], so _last_edit_overflowed was never set. Because REQUIRES_EDIT_FINALIZE=True for Telegram and _last_edit_overflowed was False, the got_done block unconditionally fired a second _send_or_edit that tried to edit M1 — triggering _edit_overflow_split and producing a third "(2/2)" chunk. Multiple tool-call iterations compounded the issue into 4-5 visible copies of the same answer. Fix: - adapter.send() now populates continuation_message_ids from any extra chunks the legacy 4096-split path delivers (symmetric with _edit_overflow_split). - The stream consumer's "First message" success path detects a non-empty continuation_message_ids and sets _last_edit_overflowed=True, causing the got_done condition to recognise full delivery and skip the redundant second finalize edit. Root cause introduced by 4ed293b (feat: native draft streaming, 2026-05-10). The companion bf1f409 wired continuation_message_ids for the edit overflow path but left the send() return without it, creating the gap closed here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Handles Bot API 10.0 guest_message updates via a two-phase delivery: - Phase 1: answerGuestQuery fires immediately with a random thinking-verb stub (e.g. "⏳ Brewing..."), returning inline_message_id. - Phase 2: stream consumer plumbs inline_message_id into its edit path — each progressive chunk becomes editMessageText(inline_message_id=...). All existing adaptive flood-control and think-block filtering apply transparently. Fallback chain: stub failure → buffer mode → answerGuestQuery on completion; flood-fallback → buffered text → final editMessageText. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ships 5 default verbs used in the answerGuestQuery stub. Kept deliberately small so users can customize without touching adapter.py — translate to another language, add emojis, or maintain a larger personal list matched to their SOUL.md persona by dropping a replacement into local-patches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Six tests covering the three delivery paths: - Streaming: send() returns imi, edit_message() routes to editMessageText, on_processing_complete is a no-op when buffer is empty. - Buffer fallback: send() accumulates text, on_processing_complete flushes via answerGuestQuery when stub failed (_imi is None). - State cleanup: on_processing_complete clears all per-query guest state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four media-independent robustness fixes to the Bot API 10.0 guest text
reply path:
- _strip_mdv2: strip leftover unclosed `**` markers. A stream cut
mid-token (e.g. "- **2") leaves the balanced-pair regex unmatched, so
the raw markdown surfaced in the delivered message.
- send(): normalize chat_id to str before the guest dict lookups. The
event source can pass chat_id as int, which silently missed every
str-keyed guest dict and dropped the reply onto the wrong path.
- send(): strip MEDIA: residuals from buffered chunks. Intermediate
streaming chunks ("MEDIA:", "MEDIA:/path") slip past the stream
consumer's extension-anchored cleanup and would buffer as visible text.
- edit_message(): keep _guest_reply_buffer current on each streaming
edit so on_processing_complete can re-render through _strip_mdv2 and
recover a finalize the stream consumer left truncated.
As a consequence on_processing_complete now always performs a final,
idempotent re-render; when the stream consumer already finalized with
identical text Telegram returns "not modified", which is downgraded from
warning to debug rather than logged as a failure.
Adds 5 unit tests covering each fix plus the end-to-end truncation
re-render.
Design note: the immediate stub fired at handler entry is intentionally
retained here. Deferring it (lazy fire) only earns its added state once
native media delivery needs the answerGuestQuery slot held open, so that
refactor — and its known gateway-rejection authz fallback — is scoped to
the follow-up guest-media PR that builds on this one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the text two-phase reply: defers the thinking stub so the
answerGuestQuery slot stays open for native media, and adds media delivery
for guest @mentions from non-member chats.
Stub timing — immediate to lazy:
_guest_inline_message_ids now uses a three-state sentinel
(False = slot open / None = fired, no imi / str = real imi). The stub
fires lazily on the first non-media tool-progress call or first text
stream (send_typing fires it early for non-media queries), keeping the
slot free when the first output is media. on_processing_complete gains
matching sentinel handling plus a slot-open authorization fallback
(empty buffer -> terse refusal rather than a lingering stub).
Media delivery:
- _guest_media_send stages a file to TELEGRAM_HOME_CHANNEL to mint a
file_id, then reserves the first item for native answerGuestQuery
(type=photo/audio/video/document); later items are noted for DM
pickup. A (path, type) file_id cache avoids re-staging on follow-ups.
- send_voice / send_image_file / send_document / send_video / send_image
route to _guest_media_send in guest chats instead of returning an error.
- send_image hoists the is_safe_url SSRF check above the guest staging
branch so guest URL-photos get the same validation as normal sends.
- Docker container paths are translated via the shared
BasePlatformAdapter.translate_docker_* helpers.
Tests: extends tests/gateway/test_telegram_guest_reply.py to 19 cases —
media staging/pending/cache/no-channel, native delivery, SSRF-before-
staging, lazy stub firing, and the media-query regex. The two-phase suite
is reconciled for the lazy sentinel.
Prerequisites: two-phase guest reply PR (machinery) and the Docker
path-translation PR (BasePlatformAdapter.translate_docker_*).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closed
5 tasks
_guest_media_send now calls translate_docker_local_paths only when the shared helper is present (it ships separately), falling back to the path as-is otherwise. MEDIA:-tagged files are already host-translated by the gateway delivery loop, so the in-method translation only affected direct send_voice/send_document calls in Docker setups. This removes the hard dependency on the Docker-path-translation work — guest media works for non-Docker and host-path media on its own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
elphamale
marked this pull request as ready for review
June 25, 2026 20:49
This was referenced Jun 27, 2026
Author
|
Closing in favor of a clean rebuild off current main: the media-delivery portion of this PR is now #56477, stacked on #56476 (which replaces #51886). Same underlying production-tested code, rebuilt cleanly against current main and without the personalized thinking_verbs.py content that had leaked into this branch. Docs in #56297 have been updated to point at the new PRs. |
elphamale
pushed a commit
to elphamale/hermes-agent
that referenced
this pull request
Jul 16, 2026
Guest mode's guest_message/answerGuestQuery flow (two-phase stub reply, deliver-token media button) ships in NousResearch#51886 and NousResearch#52639 but was never documented — the existing guest_mode section only covers the pre-Bot-API-10.0 @mention bypass. Adds the missing behavior, config (TELEGRAM_HOME_CHANNEL), and limitations (10-minute token TTL, no session stickiness) to the user-guide so operators know what to expect and how to enable file delivery. Requires NousResearch#51886 and NousResearch#52639 to be merged first — this documents behavior that doesn't exist on main yet.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds native media delivery for guest @mentions (Bot API 10.0, non-member chats) and converts the two-phase thinking stub from immediate to lazy, so the one-shot
answerGuestQueryslot can be claimed by media instead of being consumed by a text stub.Stub timing: immediate → lazy
_guest_inline_message_idsnow uses a three-state sentinel:FalseNoneinline_message_idstrinline_message_idThe stub fires lazily — on the first non-media tool-progress call, or the first text stream (
send_typingfires it early for non-media queries via_GUEST_MEDIA_QUERY_RE). If the first output is media, the slot stays open for native delivery.on_processing_completegains matching sentinel handling plus a slot-open authorization fallback: an empty buffer with the slot still open (gateway rejected the sender) resolves to a terse refusal instead of a lingering "⏳".Media delivery
_guest_media_sendstages a file toTELEGRAM_HOME_CHANNELto mint afile_id, reserves the first item for nativeanswerGuestQuery(type=photo/audio/video/document), and notes later items for DM pickup. A(path, type)file_idcache avoids re-staging on follow-up turns.send_voice/send_image_file/send_document/send_video/send_imageroute to_guest_media_sendin guest chats instead of returning an error.send_imagehoists theis_safe_urlcheck above the guest staging branch, so guest URL-photos get the same validation as normal sends.BasePlatformAdapter.translate_docker_*helper when it's present (fix(gateway): translate Docker container paths to host before media delivery #47716); otherwise the path is used as-is (see the note at the top).Note on churn
This rewrites
on_processing_completeand the stub flow that #51886 establishes. That's intentional layering: #51886 ships the text-only immediate stub (self-contained, no media); deferring it is precisely the change that lets media claim the slot, so it lands here with the feature that needs it.Tests
tests/gateway/test_telegram_guest_reply.py— 19 cases. New coverage: media staging / pending-queue /file_idcache reuse / missing-home-channel error, nativeanswerGuestQuerydelivery, SSRF-blocked-before-staging, lazy stub firing, and the media-query regex. The two-phase suite is reconciled for the lazy sentinel (the obsolete "noop" case becomes the slot-open authz-refusal test). Guest-media tests stubtranslate_docker_local_paths(the #47716 method) to identity.Test plan
🤖 Generated with Claude Code