Skip to content

feat(whatsapp): observe unmentioned group messages as session context - #104245

Open
FarelRA wants to merge 7 commits into
NousResearch:mainfrom
FarelRA:feat/whatsapp-observe-group-context
Open

FarelRA wants to merge 7 commits into
NousResearch:mainfrom
FarelRA:feat/whatsapp-observe-group-context

Conversation

@FarelRA

@FarelRA FarelRA commented Sep 6, 2026 •

Copy link
Copy Markdown

Stacked on #104133 (AST formatter). Merge #104133 first and this diff shrinks to just this feature.

What

Ports Telegram's observe_unmentioned_group_messages gate to WhatsApp — and generalizes the runner side so the separation is platform-neutral. In groups with require_mention: true, messages that pass the group allowlist but aren't addressed to the bot (no @mention, no reply-to-bot, no mention-pattern keyword) are no longer dropped silently — they're appended to the group's shared chat-scoped session transcript as observed context, with zero model/API cost. When a later mention/keyword/reply triggers a turn in that same group, the model sees the accumulated conversation flow.

Runner-neutral separation (fixes the replay bug this PR originally shipped with)

gateway/run.py's _build_gateway_agent_history previously recognized only Telegram's observed-context prompt marker. A WhatsApp adapter emitting the equivalent marker got no separation: its observed rows replayed as ordinary user history. This PR:

  • moves the whole mechanism into a platform-neutral sibling, gateway/observed_context.py: marker constants (single source both adapters build their prompts from), injection limits, assembly, and compaction;
  • generalizes the Telegram-only marker check to the shared predicate — any future platform adapter opts in by carrying its marker in the channel prompt; no per-platform runner code;
  • makes the header platform-neutral: [Observed group context - context only, not requests]. Telegram behavior is unchanged (covered by the existing suite).

Without a recognized marker the behavior is fail-closed: observed rows replay as normal history (documented + tested).

Media in observations (real artifacts, not placeholders)

The observe path downloads media to the local cache (disk-only; no agent or API calls at observation time) and records inspectable references: [image: /path] plus a vision_analyze pointer, [voice note: /path], [audio: /path], [document: /path], [video: <url>]. At trigger time the model inspects via the normal vision_analyze / read_file contract. A failed download degrades to [image (unavailable: download failed)] — observation never raises.

Bounds: rolling compaction, not truncation

The observed block is API-only on the current user turn (the transcript persists the unwrapped message), which makes it prompt-cache-safe — but it also means context compression can never shrink it. Dropping oldest rows (truncation) destroys information. Instead:

  • Rolling compaction at write time: when the verbatim observed set overflows roughly half the char budget, a background pass summarizes the OLDEST rows via the aux compression LLM (call_llm(task="compression") — same routing/limits as main compaction, secret redaction, per-row and total input bounds) into one iterative summary row appended to the transcript. Later passes update that summary in place (PREVIOUS SUMMARY + NEW TURNS, the same iterative shape the main compressor uses). No turn ever waits on the summary call (cold TTFB untouched).
  • Read path: latest summary + verbatim rows after it (the summary row is the watermark). Originals stay in the transcript, searchable via FTS — nothing is destroyed.
  • Hard cap as the valve: gateway.observed_context_max_chars (default 512,000 ~ 128K tokens, sized for common 256K-window models) / observed_context_max_rows (default 4,000). If compaction lags or the aux call fails, the cap drops oldest-first with an [... N older observed messages omitted ...] note. Both keys are config.yaml-only (no new env vars); 0 disables.
  • Cheap scheduling: a per-session pending-chars gate means a busy group does not reload its transcript on every message; a per-session lock prevents duplicate passes; a failure cooldown avoids hammering the aux LLM.
  • Platform-neutral: the same trigger fires from the shared WhatsApp mixin AND the Telegram adapter — Telegram's observed block gets the same bounds + compaction (it previously had none).

Attribution scoping

_apply_whatsapp_group_observe_attribution applies only to mention-gated, allowlisted groups: in group_policy: open groups or free_response_chats, triggered messages keep their real per-user source and clean text (ported from #38750's post-audit fix, generalized over the allowlist scope).

Cache/alternation invariants

  • The wrapper prepends to the current turn only; the persisted row is the unwrapped ctx.message (run_turn_runner.py persist_user_message), so the transcript never stores the context block — cache-safe by construction. Compaction changes the block content between turns, which is exactly the sanctioned compression exception and never touches the cached prefix (system prompt + history).
  • Removing observed user rows from replay cannot create same-role consecutive messages (alternation asserted in the runner test).
  • System prompt untouched.

Interlock with the other observe PRs

The category currently has several open PRs (#38750 whatsapp — pre-plugin-layout; #74775 qqbot; #84592 signal; #85490 whatsapp — durable persistence + 50-row window; #101121 mattermost; this one). Per the rubric's consolidation rule, the sustainable shape is a shared observed-context mechanism in the runner + thin per-platform adapters — this PR is that shape (shared gateway/observed_context.py, shared whatsapp_common.py mixin reused by bridge + WAHA, Telegram trigger included). Relative to #85490 specifically: overlapping runner separation; if it merges first I'll rebase and drop my overlapping runner diff, keeping media references, compaction, and the scoping fix. The write side here is transcript-only (no new persistence layer, no dedup store): the summary row + watermark is the mechanism, which keeps the diff small and the storage format unchanged.

Tests

  • E2E runner tests (tests/gateway/test_observed_context_runner.py) through the REAL _build_gateway_agent_history + _wrap_current_message_with_observed_context (no mocks): separation for both platforms, fail-closed replay without a marker, cap relationships, alternation survival, unwrapped persistence contract, defaults sized for large windows. Sabotage-checked: reverting the marker generalization fails 6 of these.
  • Compaction tests (tests/gateway/test_observed_context_compaction.py): watermark assembly (summary leads, pre-watermark rows skipped), hard-cap valve relationships, iterative update over the previous summary (wrapper stripped), per-row + total input bounds with redaction (secret never reaches the summarizer), aux-failure leaves the transcript untouched, scheduling gate (no transcript reload per message), no-event-loop no-op. Sabotage-checked: breaking the watermark skip or the summary append fails the suite.
  • Gating tests: 15 observe tests (observed-not-dispatched, trigger dispatch instead, allowlist enforcement, DM exclusion, chat-scoped source, attribution + safety prompt, media reference with cached path + vision note, download-failure degradation, scoping x3). Full tests/gateway/ + tests/plugins/ + tests/conformance/ green: 9646 passed, 0 failed on this branch.
  • Honest limits: a live end-to-end compaction (real aux LLM + real transcript) is not exercised by CI; the LLM boundary is faked at _call_summary_llm and everything around it runs the real code. End-to-end send/download against a live WhatsApp bridge is likewise not exercised by CI; the media path is covered at the adapter boundary with mocked downloads.

Docs

website/docs/user-guide/messaging/whatsapp.md: opt-in, media references, compaction + bounds + retention honesty, scoping note.

@FarelRA
FarelRA requested a review from a team September 6, 2026 11:44
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery platform/whatsapp WhatsApp Business adapter labels Sep 6, 2026
@alt-glitch

Copy link
Copy Markdown

This was generated by AI during triage.

Related: this implements #38710 and competes with the older open #38750 for the same feature (observed unmentioned WhatsApp group context). Linking so reviewers can compare; #104247 (WAHA) stacks on this.

@FarelRA
FarelRA force-pushed the feat/whatsapp-observe-group-context branch 2 times, most recently from d4d8cae to 96d906d Compare September 6, 2026 16:36
@FarelRA

FarelRA commented Sep 6, 2026

Copy link
Copy Markdown
Author

Reworked after the maintainer-standards pass: truncation replaced by rolling compaction

The earlier revision bounded the observed block at read time by dropping the oldest rows. That destroys information, so it is replaced with a rolling compaction mechanism in a new platform-neutral sibling, gateway/observed_context.py:

Write path (eager, background). When the verbatim observed set overflows roughly half the char budget, the observe appenders (shared WhatsApp mixin + Telegram adapter) schedule a fire-and-forget background pass. It summarizes the OLDEST rows via the aux compression LLM (call_llm(task="compression") — same routing, semaphore, and redaction the main compressor uses) into one iterative summary row appended to the transcript. Later passes feed PREVIOUS SUMMARY + NEW TURNS back in, so the summary updates in place instead of restarting. No turn ever waits on the summary call.

Read path. The latest summary row is the watermark: the injected block is the summary + verbatim rows after it. Originals stay in the transcript and searchable — nothing is deleted. A hard cap (gateway.observed_context_max_chars, default 512,000 chars ~ 128K tokens for common 256K-window models; observed_context_max_rows, default 4,000) remains purely as the last-resort valve with the omission note.

Platform-neutral. Marker constants now live in the sibling as the single source; the WhatsApp mixin and the Telegram adapter both build their prompts from them and both trigger compaction. Telegram's observed block previously had no bound at all — it gets the same treatment.

Scheduling hygiene. A per-session pending-chars gate (spawn only past half the budget) means a busy group does not reload its transcript on every message; a per-session lock prevents duplicate passes; a 5-minute failure cooldown avoids hammering the aux LLM.

Tests. New tests/gateway/test_observed_context_compaction.py (watermark assembly, iterative update, redaction bounds — a planted secret never reaches the summarizer prompt, aux-failure safety, scheduling gate) and the runner e2e file updated for the new semantics. Both sabotage-checked (breaking the watermark skip, the summary append, or the marker generalization fails the suite). Full tests/gateway/ + tests/plugins/ + tests/conformance/: 9646 passed, 0 failed.

Also in this push: defaults raised to 512K/4K per the sizing rationale above, and the stale 4,096 chunking paragraph in the docs fixed (on #104133, where the cap change lives).

@FarelRA

FarelRA commented Sep 6, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (176 new commits). The only overlap was gateway/run.py: upstream's timestamp-replay rework (08f269f) touches the same loop this PR modifies. Resolution keeps both behaviors: observed rows render timestamps like upstream does, but onto a copied row so the transcript stays untouched and the sibling's assembly reads the rendered content. Full suite re-run green: 9673 passed, 0 failed (gateway + plugins + conformance). #104247 rebased on top via rebase --onto (skipping the superseded observe commit).

…t-py)

Replace the regex transcoder in WhatsAppBehaviorMixin.format_message with
an AST-based CommonMark renderer so every markdown construct is handled —
emphasis, headings, fenced code (language tag kept as a caption), GFM
tables, links, images, lists, blockquotes, horizontal rules and HTML —
with nothing silently dropped.

- gateway/platforms/whatsapp_renderer.py: CommonMarkToWhatsApp walks the
  markdown-it-py token stream and emits WhatsApp's native dialect
  (*bold* / _italic_ / ~strike~ / backticks). Constructs WhatsApp cannot
  render are down-rendered, never discarded. Prose containing literal
  * _ ~ backtick passes through with stray open→close pairs broken by
  space-flanking the offending open (WhatsApp pairs delimiters per line
  only; a lone ~ meaning 'approx' would otherwise strike on-device).
- gateway/platforms/whatsapp_unicode.py: optional Unicode font styling
  (default ON, WHATSAPP_UNICODE_FORMATTING=false to disable) recovers
  heading hierarchy native WhatsApp flattens; unassigned math glyphs
  (U+1D455 → ℎ) are substituted to avoid tofu.
- Tables default to a wrap-safe key/value flatten; aligned monospace
  fence stays available via WHATSAPP_TABLE_MODE=monospace.
- MAX_MESSAGE_LENGTH raised 4096 → 65,536 (WhatsApp's real per-message
  cap): send and edit both stay ONE bubble — no UX fragmentation, and a
  split edit can no longer surface chunk 2+ as unlinked duplicates.
- Declare markdown-it-py==4.0.0 explicitly (already resolved transitively
  via rich; the renderer imports it directly) and exempt the exact pin
  from exclude-newer per the release-day brick pattern.
send() already converted markdown, but three paths sent raw text:

- edit_message: streaming edits delivered the accumulated buffer without
  conversion, so previews AND the final message showed raw # / ** markers.
  Also surface the bridge's messageIds when an oversized edit splits, so
  the stream consumer re-targets subsequent edits at the last visible
  bubble (mirrors send()'s continuation contract) instead of reporting
  the original id while chunks 2+ landed as unlinked new messages.
- media captions (bridge /send-media and Cloud API media block): captions
  are rendered with WhatsApp formatting on-device, so a raw markdown
  caption leaked # / ** onto the media bubble.
- _standalone_send (cron delivery): runs in a separate process and bypasses
  adapter.send(); body text and single-file captions now go through
  format_message with the same never-raise guard as the gateway path.
… 65,536 cap

- splitLongMessage moves to bridge_helpers.js and counts/splits by code
  points (codePointLength): astral characters (emoji, the Unicode math
  glyphs the formatter emits) count once and can never be split mid-
  surrogate-pair. The legacy UTF-16 splitter counted them at 2x, so a
  ~3000-char heading-formatted edit crossed a 4096 threshold and split.
- /send and /edit both split at WhatsApp's real per-message cap
  (WHATSAPP_MAX_MESSAGE_LENGTH, default 65,536 code points). There is no
  smaller edit cap: an edit past a smaller limit sends chunk 2+ as
  brand-new unlinked messages — the duplicate-bubble regression — and a
  smaller send cap just fragments what WhatsApp renders fine as one
  message. /edit keeps reporting messageIds so the adapter can surface
  continuation ids.
- test_whatsapp_formatting.py: full-construct CommonMark→WhatsApp matrix
  (inline emphasis, headings incl. setext, fenced code captions, links,
  images, lists, task lists, blockquotes, HR, tables both modes, HTML,
  entities, mixed documents, unterminated-fence degradation), stray
  delimiter flanking (per-line pairing, heading/list contexts, star and
  underscore literals never flanked), GlyphStyler + unassigned-glyph
  substitution, unicode_formatting toggle (env + config.extra),
  format+truncate pipeline (balanced fences per chunk at the 65,536 cap),
  and every-send-path-formats coverage (streaming edits, media captions,
  standalone/cron) incl. edit continuation ids.
- test_whatsapp_cloud.py: Cloud API caption conversion + long-message
  chunking at the real cap regressions.
- test_whatsapp_send_message_media.py: standalone body + caption
  conversion regressions.
- test_whatsapp_native_delivery.py: sanitizer output is fully consumed
  as paragraph indentation now (clean 'text', not ' text').
- generate_conformance_vectors.py: pin the whatsapp oracle to the default
  pipeline config (committed vectors must not depend on the developer's
  WHATSAPP_UNICODE_FORMATTING), add tilde-approx / tilde-space-safe /
  tilde-mixed / header-h3-italic-h grid vectors, table vectors expect
  parity under the default flatten mode, backslash-in-code documented as
  divergent (invalid CommonMark kept losslessly).
- Regenerated conformance vectors (whatsapp 4 new; telegram/slack/discord
  re-stamped with the generator run).
…nd cap

register() still advertised max_message_length=4096 while the adapter and
formatter run at the 65,536 code-point cap; setup surfaces derived from the
registry understated the real limit.
The formatter raises the per-message limit from the legacy 4,096 UX split to
WhatsApp's actual cap; the docs section still described the old behavior.
…ng compaction

- gateway/observed_context.py (new platform-neutral sibling): marker constants
  (single source for Telegram + WhatsApp prompts), injection limits
  (gateway.observed_context_max_chars default 512,000 ~ 128K tokens for common
  256K-window models; observed_context_max_rows default 4,000), read assembly,
  and rolling compaction.
- gateway/run.py: generalize the Telegram-only observed-context marker check to
  the shared predicate so WhatsApp observed rows are separated from replayable
  history the same way Telegram's are (previously they replayed as ordinary user
  turns); header text is platform-neutral; assembly delegates to the sibling.
- Rolling compaction instead of truncation: when the verbatim observed set
  overflows roughly half the char budget, a background pass summarizes the OLDEST
  rows via the aux compression LLM (task routing, secret redaction, per-row and
  total input bounds) into ONE iterative summary row appended to the transcript.
  Originals stay searchable; nothing is destroyed. Later passes update the
  summary in place. Read path serves latest summary + verbatim rows after the
  watermark; the hard cap (oldest dropped, omission note) remains as the
  last-resort valve. Per-session lock + failure cooldown; cheap pending-chars
  gate so a busy group does not reload its transcript per message.
- Write-side trigger in the shared WhatsApp mixin and the Telegram adapter
  (fire-and-forget, never raises); prompt markers built from the shared constants.
- Attribution scoping: _apply_whatsapp_group_observe_attribution applies only to
  mention-gated, allowlisted groups; open / free-response groups keep per-user
  source and clean text (ports the NousResearch#38750 post-audit fix).
- Real media in observations: the observe path downloads media to the local cache
  and records inspectable references ([image: path] + vision_analyze pointer,
  [voice note: path], [document: path]) instead of kind placeholders; failed
  downloads degrade to an unavailable note.
- End-to-end runner tests through the real _build_gateway_agent_history +
  wrapper (separation for both platforms, fail-closed replay without a marker,
  cap relationships, alternation survival, unwrapped persistence contract) plus
  compaction unit tests (watermark assembly, iterative update, redaction bounds,
  aux-failure safety, scheduling gate). Sabotage-checked.

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 P3 Low — cosmetic, nice to have platform/whatsapp WhatsApp Business adapter type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants