feat: group addressee detection — stop responding when not actually spoken to#2480
Merged
houko merged 3 commits intoApr 14, 2026
Merged
Conversation
added 3 commits
April 14, 2026 13:08
… group gating
Phase 2 §C — OB-04, OB-05, OB-06, GS-01 (minimal), OBS-01, BC-02.
is_vocative_trigger(text, pattern) — strict positional match (start of
turn or after [.!?] boundary) that REJECTS when another capitalized
vocative precedes the pattern. Closes the Beeper screenshot case where
'Caterina, chiedi al Signore...' triggered on substring 'Signore'.
is_addressed_to_other_participant(text, participants, agent_name) —
detects 'Name,' / 'Name!' opening when Name belongs to a roster member
other than the agent. Used as a hard guard before pattern evaluation.
Both are wired into should_process_group_message under the feature flag
LIBREFANG_GROUP_ADDRESSEE_GUARD (default off per D-§C-6 — 1 week
observation window). Legacy substring matcher remains as rollback path.
types.rs: new ParticipantRef { jid, display_name } + SenderContext gains
group_participants: Vec<ParticipantRef> with #[serde(default)] (BC-02).
build_sender_context forwards roster from message metadata.
OBS-01 structured log emitted on every gating decision (group_gating_skip
with reason, or group_gating_pass).
Owner-no-bypass audit (OB-06): grep for is_owner / owner_jid in
librefang-channels found ZERO matches — no short-circuit to remove. The
absence is asserted by an integration test (owner-style 'ciao a tutti'
without mention is still rejected).
24 new tests (vocative=11, addressee=7, gating-v2=5, bc02=1). Full suite
716 passed. Clippy clean.
… hook
Phase 2 §C — GS-01 (minimal), OBS-01.
Adds a 5-minute TTL cache (groupMetadataCache) for sock.groupMetadata()
results, populated lazily on the first inbound from each group JID.
Subscribes to Baileys 'group-participants.update' to invalidate the
matching entry on adds/removes/promotions so roster changes become
visible at the next message.
The roster is forwarded to the kernel inside the inbound payload as
group_participants: [{jid, display_name}, ...]. The kernel-side bridge
already consumes this field via SenderContext.metadata.group_participants
(committed separately in librefang-channels) — the addressee guard
(LIBREFANG_GROUP_ADDRESSEE_GUARD=on) reads it to decide whether the turn
is for the bot or for another named participant.
Graceful degradation: failed sock.groupMetadata() calls return [] and
emit { event: 'group_roster_fetch_failed' } — the addressee guard
becomes a no-op and the legacy substring matcher continues to apply.
OBS-01 structured logs:
group_roster_fetched — first hit, network call landed
group_roster_cache_hit — TTL-cached
group_roster_invalidated — Baileys event drained the entry
group_roster_fetch_failed — exception path
Tests: 5 new in test/group-roster.test.js (3-call cache, invalidation
re-fetch, fetch failure graceful, non-group early-return, falsy JID).
All pass under node --test. Existing index.test.js suite (61 tests)
still green.
…text The WhatsApp gateway forwards a group_participants roster on group-chat POSTs so the kernel's addressee guard can detect when a turn is addressed to a named participant other than the agent. The HTTP route previously dropped this payload on arrival because MessageRequest had no slot for it and request_sender_context never threaded it into SenderContext. - Add MessageRequest.group_participants: Option<Vec<ParticipantRef>> with #[serde(default)] so Telegram and direct API callers that omit the field keep deserializing cleanly. - Thread the value into SenderContext.group_participants (existing field with #[serde(default)] — empty roster = guard no-op, no false positives). - Introduce ParticipantRefSchema as a utoipa stand-in for the real type (which lives in librefang-channels, a crate without utoipa) via #[schema(value_type = ...)] so the OpenAPI document still reflects the shape without adding a new dependency. - Update existing request_sender_context tests to construct the new field; add two tests asserting the roster survives JSON round-trip and backward compat when omitted.
houko
enabled auto-merge (squash)
April 14, 2026 13:01
houko
approved these changes
Apr 14, 2026
Contributor
Author
|
Pushed |
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
Phase 2 §C of the openclaw-style output-boundary overhaul. Stops the agent from responding in group chats when the turn is addressed to another participant, and closes an implicit owner-in-group bypass that sidestepped the `mention_only` gating.
Original bug from production (observed in a family group chat): user A addresses user B by name (", do something with ") — the bot replied as though it had been summoned by the mere mention of its owner-trigger pattern (""), even though the turn's syntactic addressee was user B, not the bot. Group gating matched the trigger pattern anywhere in the turn, with no notion of vocative position or other-participant addressee detection.
Changes
Test plan
Known follow-up (not in this PR)
The addressee guard reads group participants from `ChannelMessage.metadata` inside `bridge.rs`. HTTP-ingested messages from the WhatsApp gateway take the `routes/agents.rs → kernel.send_message_with_sender_context` path, which populates `SenderContext.group_participants` but does NOT construct a `ChannelMessage` for the bridge. A sibling PR will either:
Until that lands, the guard is active for in-process adapter-driven messages (Telegram/Discord) and the gateway carries the roster for observability. Feature flag `LIBREFANG_GROUP_ADDRESSEE_GUARD` will stay off in production for WhatsApp until the wiring closes.
Rollback
Flag off restores the previous `matches_group_trigger_pattern` semantics.