Skip to content

feat(whatsapp): durably observe approved group history - #85490

Open
NehoraiHadad wants to merge 7 commits into
NousResearch:mainfrom
NehoraiHadad:feat/whatsapp-group-history-backfill
Open

NehoraiHadad wants to merge 7 commits into
NousResearch:mainfrom
NehoraiHadad:feat/whatsapp-group-history-backfill

Conversation

@NehoraiHadad

@NehoraiHadad NehoraiHadad commented Aug 13, 2026 •

Copy link
Copy Markdown

Summary

  • Durably persist unmentioned messages from explicitly approved, mention-gated WhatsApp groups without dispatching the agent.
  • Give addressed turns one bounded, context-only window of the latest 50 observed messages.
  • Key WhatsApp text debounce by the raw chat and sender so simultaneous messages from different group members can never merge.
  • Route ambient and addressed non-command messages to one shared group session while retaining the real sender for slash-command authorization.
  • Preserve the original WhatsApp timestamp, message id, sender/chat provenance, and structured display metadata.

Problem

With require_mention: true, WhatsApp drops ambient group messages before a MessageEvent reaches the session store. The next addressed turn therefore has neither durable searchable history nor the nearby conversation that made a follow-up meaningful.

The initial implementation also exposed three edge cases:

  1. shared group routing made the debounce key identical for every sender, so concurrent addressed messages could coalesce;
  2. observed=True rows were withheld from ordinary user replay only when an adapter prompt marker was present;
  3. durable observed history and the RAM backfill could inject the same ambient messages twice.

Behavior

Both observation and legacy RAM backfill remain opt-in:

whatsapp:
  require_mention: true
  group_policy: allowlist
  group_allow_from:
    - 120363001234567890@g.us
  observe_unmentioned_group_messages: true
  observe_allowed_chats:
    - 120363001234567890@g.us
  history_backfill: true
  history_backfill_limit: 50

For an approved ambient message, the adapter:

  1. verifies that it was rejected only by the mention gate;
  2. writes one observed=True user row to the existing SessionStore / state.db;
  3. deduplicates by platform message id;
  4. stops before agent dispatch, typing, read receipt, or outbound delivery.

The gateway removes observed rows from replay regardless of adapter prompt markers. Only an observer-marked addressed group turn may render them, and it receives at most the latest 50 as a separate context-only block. Markerless cron, send_message, and non-adapter dispatches receive no observed chatter. When durable observation is active for that chat, the duplicate RAM channel_context is suppressed. When durable observation is inactive, the existing RAM backfill behavior is preserved.

WhatsApp text batching uses raw (profile, chatId, senderId) identity, while the eventual agent session remains shared at the group level. Commands deliberately retain their sender-scoped source and original text so existing slash authorization remains fail-closed.

Scope and privacy

  • Observation never inherits an unrestricted group_policy: open.
  • observe_allowed_chats must resolve to an explicit non-empty group allowlist; it can narrow group_allow_from.
  • Disallowed groups and broadcasts are neither buffered nor persisted.
  • A persistence failure logs and drops the observation; it never turns ambient chatter into an agent request.
  • No new database, worker, or inference path is introduced.

Tests

  • allowed ambient message: one durable observed row, zero dispatch;
  • disallowed group: zero buffer and zero persistence;
  • addressed turn: shared session and one non-duplicated durable context block;
  • without a prompt marker, observed rows are neither replayed nor rendered;
  • observed replay is bounded to the latest 50 messages;
  • simultaneous addressed messages from different senders use separate debounce slots;
  • RAM backfill remains available when durable observation is inactive;
  • slash command sender identity remains intact;
  • duplicate platform message id: one row;
  • original timestamp, display metadata, FTS visibility, and restart persistence;
  • YAML-to-platform configuration bridging;
  • existing WhatsApp, Telegram, replay, timestamp, and auto-continue regressions.

Validation on current main:

174 passed, 2 skipped
ruff: clean
git diff --check: clean

This supersedes the PR's original backfill-only implementation while preserving its immediate 50-message context goal exactly once.

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/whatsapp WhatsApp Business adapter P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 13, 2026
@NehoraiHadad

Copy link
Copy Markdown
Author

Live deployment datapoint. We ran this branch (merged together with the botIds device-suffix fix) against a real WhatsApp group: Baileys linked-device session, WHATSAPP_MODE=bot, require_mention: true with a custom wake pattern, history_backfill: true, history_backfill_limit: 50.

Observed over a multi-activation session:

  • Unmentioned group chatter produced no responses and no agent invocations (mention gating intact).
  • The next gated activation arrived with the [Recent group messages] block carrying the interim chatter, and the model correctly answered a question whose answer existed only in that unmentioned chatter — the injection does real work end-to-end, not just prompt decoration.
  • The very first activation after connect already carried backfill of a message sent before the wake word.
  • No duplicate injection or ordering issues across the session; prompt-cache hit rates stayed high (79–95%) with the injected block present.

Happy to share redacted logs if useful.

@NehoraiHadad
NehoraiHadad force-pushed the feat/whatsapp-group-history-backfill branch from 2daa140 to 226aa1c Compare August 16, 2026 10:16
@NehoraiHadad NehoraiHadad changed the title feat(whatsapp): group history backfill via channel_context feat(whatsapp): durably observe approved group history Aug 16, 2026
@NehoraiHadad
NehoraiHadad force-pushed the feat/whatsapp-group-history-backfill branch from 226aa1c to 9a1c45d Compare August 16, 2026 10:26
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference, author can ignore or act on any point.

feat(whatsapp): durably observe approved group history

Well-structured feature with thorough tests: per-(chat, sender) watermarks, ring-buffer bound, per-chat isolation, and prompt-injection care (neutralized names, [unverified] tags, preamble) are all covered.

  1. gateway/platforms/whatsapp_common.py:528 — only sender names are neutralized; message bodies are injected verbatim into channel_context. A multi-line body (WhatsApp messages can contain newlines) is only defended by the [unverified] preamble for unauthorized senders; a verified/allowlisted sender's body is treated as trusted and its markdown/newlines can blur the [Recent group messages] block boundary. Consider collapsing body newlines (like the name handling) or otherwise bounding each line so the block stays a flat list of [name] text lines.
  2. The feature is wired only into the native bridge adapter's _build_message_event (plugins/platforms/whatsapp/adapter.py:1474); the Cloud API adapter shares WhatsAppBehaviorMixin but never calls _maybe_record_group_history, so history_backfill silently does nothing there. Either wire it or document the limitation.
  3. Minor: the title says "durably observe" but the buffer is explicitly in-memory and reset on restart (documented in the docs). Also, _build_group_channel_context advances the watermark before the event is fully built — if _build_message_event fails after context construction (e.g., image download error), that sender's buffered context is consumed and lost for the current batch. Consider advancing the watermark only once the event is actually delivered.
  4. The _enqueue_text_event merge copies channel_context only when existing lacks one; in the rare case both events carry context, the later one is dropped. Given the watermark advances per build, a follow-up merge could double-inject — acceptable, but the interaction is subtle enough to warrant a comment.

@NehoraiHadad

Copy link
Copy Markdown
Author

Full live E2E validation (2026-08-16). Ran this branch (as community-engine-v0.2.2: this PR + the LID authz work) on a production-shaped single-home deployment: real Baileys linked-device session, gateway.multiplex_profiles: true with the observed group routed to its own profile, require_mention: true + custom wake pattern, observe_unmentioned_group_messages: true, observe_allowed_chats pinned to exactly one group.

Verified end-to-end on a live group:

  • Unmentioned message → zero outbound, durably recorded under the routed profile (shared-transcript source, NO_REPLY marker, sender attribution preserved in-content).
  • Wake-word message → normal routed turn and reply.
  • A later "what was written here?" turn retrieved the silently observed message with full provenance (group id/name, sender id/name) — the observe → store → retrieve chain closes.
  • A group deliberately left OFF every allowlist received real traffic (fresh sender-keys on disk) and produced zero sessions and zero stored rows.

Two deployment findings from the same session, filed separately since they're pre-existing and not caused by this PR: #87830 (routed-turn authorization never consults whatsapp.group_allow_from from config — env var only, silent drop) and #87833 (restart can orphan the bridge and silently kill inbound delivery).

On the review points above: (1) agreed — collapsing body newlines like the name handling is cheap hardening, will fold into this branch; (2) correct, native-bridge only for now — will state that explicitly in the docs section; (3) fair point on the watermark advancing before the event is fully built, and on "durably" — the durable half is the store, the backfill window is in-memory as documented; (4) will add the comment on the merge interaction.

@NehoraiHadad

Copy link
Copy Markdown
Author

Review follow-ups pushed (d691f3b, 74bf411, 09a80c8).

  1. Body newlines (d691f3b) — message bodies now go through the same neutralize_untrusted_inline_text the sender names already used, with max_chars=0 so the body is not truncated (the size bound stays the ring buffer, not the line). The block is a flat list of [name] text lines again for every sender, allowlisted or not. New test test_observed_body_newlines_collapse_into_one_context_line feeds a body containing a forged [Recent group messages] header and asserts one buffered message renders as exactly one line.

  2. Cloud API limitation (74bf411) — documented rather than wired. Confirmed the read: WhatsAppCloudAdapter inherits WhatsAppBehaviorMixin but never calls _maybe_record_group_history / _build_group_channel_context / the observe path, so both history_backfill and observe_unmentioned_group_messages are no-ops there. Stated in the WhatsApp guide, in both env-var reference rows, and at the mixin section comment so the next person editing the helpers sees it.

  3. Watermark ordering (d691f3b) — comment at the update site. The trade is deliberate: advancing at render time costs one lost window if the event is dropped afterwards, while advancing on delivery would replay the entire window on every failed build. One correction to the review's example — the media download runs before _build_group_channel_context, not after, so an image-download error cannot consume the window.

  4. Batch merge (09a80c8) — comment at the merge site explaining why only the first channel_context in a batch survives: the watermark advances once per build, so batched events carry disjoint windows; concatenating would splice a second block header into the prompt, and keeping the later one would drop the older window. The cost is bounded by the batch quiet period, and the durable observe path still records those messages.

Also from the review: "durably observe" refers to the store (one row per message, survives restart), not the backfill window, which the docs already describe as bounded and in memory.

Tests: uv run pytest tests/gateway/test_whatsapp_group_observer.py (14 passed) and uv run pytest tests/gateway -k whatsapp (194 passed, 3 skipped).

NehoraiHadad and others added 7 commits September 1, 2026 11:42
The observed-history block is a flat list of "[name] text" lines, but only
sender names were neutralized; bodies were injected verbatim. WhatsApp
messages can contain newlines, so any sender could forge extra lines or a
second "[Recent group messages]" header inside the block. Bodies now go
through the same neutralizer as names, with the length bound left to the
ring buffer rather than the per-line cap.

Also documents the two ordering decisions reviewers asked about: when the
per-sender watermark advances relative to delivery, and that these helpers
are only called by the native Baileys adapter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoHsiYcUqXNyasB1Tvq2TY
Both group-history features are wired into the local Baileys adapter only.
The Cloud API adapter inherits the shared behavior mixin but never calls
them, so history_backfill and observe_unmentioned_group_messages silently
do nothing on a Cloud API connection. Say so where the settings are
documented instead of leaving it to be discovered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoHsiYcUqXNyasB1Tvq2TY
Keeping only the first context of a batch is the dedup rule, not an
oversight: the watermark advances once per build, so batched events carry
disjoint windows and merging them would splice a second block header into
the prompt. Record the trade-off at the merge site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoHsiYcUqXNyasB1Tvq2TY
@NehoraiHadad
NehoraiHadad force-pushed the feat/whatsapp-group-history-backfill branch from 09a80c8 to 723d02d Compare September 1, 2026 08:44
@NehoraiHadad

Copy link
Copy Markdown
Author

Rebased onto current NousResearch/hermes-agent/main and force-pushed with lease.

New head: 723d02d4.

Conflict resolved in plugins/platforms/whatsapp/adapter.py: kept batching isolated by profile + raw chat + sender so distinct group authors cannot coalesce into one text batch.

Validation:

  • uv run --with pytest pytest tests/gateway/test_whatsapp_group_observer.py — 14 passed
  • uv run --with pytest --with pytest-asyncio --with aiohttp pytest tests/gateway -k whatsapp — 195 passed, 5 skipped

This branch has not been deployed

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

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/whatsapp WhatsApp Business adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants