Repository navigation
Conversation
d4d8cae to
96d906d
Compare
Reworked after the maintainer-standards pass: truncation replaced by rolling compactionThe 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, 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 ( 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 ( 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 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). |
96d906d to
fcc18fd
Compare
|
Rebased onto current |
…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.
0ac0719 to
2921278
Compare
What
Ports Telegram's
observe_unmentioned_group_messagesgate to WhatsApp — and generalizes the runner side so the separation is platform-neutral. In groups withrequire_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_historypreviously 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:gateway/observed_context.py: marker constants (single source both adapters build their prompts from), injection limits, assembly, and compaction;[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 avision_analyzepointer,[voice note: /path],[audio: /path],[document: /path],[video: <url>]. At trigger time the model inspects via the normalvision_analyze/read_filecontract. 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:
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).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);0disables.Attribution scoping
_apply_whatsapp_group_observe_attributionapplies only to mention-gated, allowlisted groups: ingroup_policy: opengroups orfree_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
ctx.message(run_turn_runner.pypersist_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).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, sharedwhatsapp_common.pymixin 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
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.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.tests/gateway/+tests/plugins/+tests/conformance/green: 9646 passed, 0 failed on this branch._call_summary_llmand 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.