Skip to content

feat: group addressee detection — stop responding when not actually spoken to#2480

Merged
houko merged 3 commits into
librefang:mainfrom
f-liva:feat/output-boundary-c-addressee-detection
Apr 14, 2026
Merged

feat: group addressee detection — stop responding when not actually spoken to#2480
houko merged 3 commits into
librefang:mainfrom
f-liva:feat/output-boundary-c-addressee-detection

Conversation

@f-liva

@f-liva f-liva commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

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

  1. `is_vocative_trigger(text, pattern)` in `crates/librefang-channels/src/bridge.rs` — matches only at the start of the turn or after strong punctuation (`.!?`), and rejects if a capitalized vocative-like token precedes the pattern in the same turn. Replaces the previous `regex.is_match` logic.
  2. `is_addressed_to_other_participant(text, roster, agent_name)` — if the turn starts with `,` and `Name` is in the group roster but not the agent's canonical name, returns true → `should_process_group_message` abstains.
  3. `SenderContext.group_participants: Vec` (`#[serde(default)]`) — populated from the gateway via `sock.groupMetadata(jid)` cached 5min with invalidation on Baileys `group-participants.update` events. Threaded through `request_sender_context` in `crates/librefang-api/src/routes/agents.rs`. Non-WhatsApp adapters are unaffected (default empty Vec).
  4. Owner-in-group audit — grepped `is_owner`, `owner_jid`, etc. across `crates/librefang-channels/src/`: zero short-circuits to remove. OB-06 is satisfied by absence of bypass; regression guarded by integration test `owner_no_mention_no_pattern_rejected`.
  5. Flag `LIBREFANG_GROUP_ADDRESSEE_GUARD=on` default-off for 1 week of observation, then on by default.

Test plan

  • 11 `vocative_tests` (start-of-turn, post-punctuation, reject other-vocative-before-pattern, case-insensitivity)
  • 7 `addressee_tests` (other-participant vocative, agent-addressed, non-vocative text)
  • 5 `should_process_group_message_v2` integration tests (owner-no-mention rejected, owner-with-mention accepted, legacy-flag-off backward compat)
  • 1 `bc02_tests` serde backward compat on `group_participants`
  • 5 gateway `group-roster.test.js` (cache + invalidation + fetch-failure fallback + non-group + falsy)
  • 3 new route tests for `MessageRequest.group_participants` (threaded into SenderContext, default-when-missing, deserialize-from-json)
  • `cargo test -p librefang-channels`: 716 pass
  • `cargo test -p librefang-api --lib`: 308 pass
  • gateway `node --test`: 61 pass baseline + 5 new roster

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:

  • Extend the kernel dispatch path to evaluate the addressee guard against `SenderContext.group_participants` directly, or
  • Construct a synthetic `ChannelMessage` so the existing bridge gating applies uniformly.

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.

Federico Liva 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.
@github-actions
github-actions Bot requested a review from houko April 14, 2026 11:35
@github-actions github-actions Bot added area/channels Messaging channel adapters area/sdk JavaScript and Python SDKs ready-for-review PR is ready for maintainer review labels Apr 14, 2026
@houko houko added size/XS < 10 lines changed size/L 250-999 lines changed and removed size/XS < 10 lines changed labels Apr 14, 2026
@houko
houko enabled auto-merge (squash) April 14, 2026 13:01
@houko
houko merged commit 9e84ea4 into librefang:main Apr 14, 2026
15 checks passed
@github-actions github-actions Bot removed the ready-for-review PR is ready for maintainer review label Apr 14, 2026
@f-liva

f-liva commented Apr 14, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 5113d65a localizing test fixtures and doc comments to English (Boss/Alice/Bob naming, neutral phrasing).

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

Labels

area/channels Messaging channel adapters area/sdk JavaScript and Python SDKs size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants