NousResearch/hermes-agent

Matrix DM room misclassification fixes and detection logic

Cluster 48551 · gateway. Data as of

Nothing open on GitHub. Fix merged in #51731 on 24 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #51731. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #51731. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple bug reports and fixes addressing Matrix DM rooms being incorrectly classified as group rooms, requiring @mentions. Approaches include using m.direct account data, member count, and recording DM rooms on invite as classification signals.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #51731 fix(matrix): use member_count as DM signal for named DM rooms (#48551)

    merged to main

    files: plugins/platforms/matrix/adapter.py, tests/gateway/test_matrix.py, tests/gateway/test_matrix_project_context_isolation.py

    Show the diff
    • plugins/platforms/matrix/adapter.py
      @@ -3573,21 +3573,29 @@ def _state_event_value(event: Any, key: str) -> Optional[str]:
               return None
       
           async def _get_room_member_count(self, room_id: str) -> Optional[int]:
      +        # Tier 1: state_store (fast, cache-backed).
               state_store = (
                   getattr(self._client, "state_store", None) if self._client else None
               )
      -        if not state_store:
      -            return None
      -        try:
      -            members = await state_store.get_members(room_id)
      -        except Exception:
      -            return None
      -        if members is None:
      -            return None
      -        try:
      -            return len(members)
      -        except TypeError:
      -            return None
      +        if state_store:
      +            try:
      +                members = await state_store.get_members(room_id)
      +                if members is not None:
      +                    return len(members)
      +            except Exception:
      +                pass
      +
      +        # Tier 2: API fallback (direct server query) when the cache is empty.
      +        client = getattr(self, "_client", None)
      +        if client is not None and hasattr(client, "joined_members"):
      +            try:
      +                resp = await client.joined_members(room_id)
      +                if getattr(resp, "members", None) is not None:
      +                    return len(resp.members)
      +            except Exception:
      +                pass
      +
      +        return None
       
           async def _get_room_name(self, room_id: str) -> Optional[str]:
               if not self._client or not hasattr(self._client, "get_state_event"):
      @@ -3674,8 +3682,23 @@ async def _resolve_room_identity(
               member_count = await self._get_room_member_count(room_id)
               has_explicit_name = bool(room_name)
               is_direct = bool(self._dm_rooms.get(room_id, False))
      -        conflict = bool(is_direct and has_explicit_name)
      -        chat_type = "dm" if is_direct and not has_explicit_name else "room"
      +        # member_count is the primary DM signal: <=2 members means this is
      +        # necessarily a 1:1 conversation (or self-DM), regardless of m.direct
      +        # or room name. Most Matrix clients auto-name DM rooms (e.g.
      +        # "Alice & Bot"), so the old `not has_explicit_name` check
      +        # misclassified virtually all client-created DMs as rooms. Falls back
      +        # to the m.direct + name heuristic when the count is unavailable (e.g.
      +        # state_store and API query both fail). A room that grew to 3+ members
      +        # but is still in stale m.direct is correctly classified as a room.
      +        is_likely_dm = (member_count is not None and member_count <= 2) or (
      +            is_direct and not has_explicit_name
      +        )
      +        conflict = bool(
      +            is_direct
      +            and has_explicit_name
      +            and (member_count is None or member_count > 2)
      +        )
      +        chat_type = "dm" if is_likely_dm else "room"
               display_name = room_name or canonical_alias or room_id
       
               identity = MatrixRoomIdentity(
    • tests/gateway/test_matrix.py
      @@ -502,8 +502,9 @@ async def test_m_direct_room_is_dm(self):
               assert await self.adapter._is_dm_room("!dm_room:ex.org") is True
       
           @pytest.mark.asyncio
      -    async def test_named_two_member_room_is_not_dm(self):
      -        """A named two-member room must remain a room, not a DM."""
      +    async def test_named_two_member_room_is_dm_by_member_count(self):
      +        """A named two-member room NOT in m.direct is treated as a DM because
      +        <=2 members means it's necessarily a 1:1 conversation."""
               self.adapter._joined_rooms = {"!project:ex.org"}
               self.adapter._dm_rooms = {}
               self.adapter._client = MagicMock()
      @@ -519,14 +520,41 @@ async def test_named_two_member_room_is_not_dm(self):
       
               identity = await self.adapter._resolve_room_identity("!project:ex.org")
       
      -        assert identity.chat_type == "room"
      +        assert identity.chat_type == "dm"
               assert identity.display_name == "Project Room"
               assert identity.joined_member_count == 2
      -        assert await self.adapter._is_dm_room("!project:ex.org") is False
      +        assert await self.adapter._is_dm_room("!project:ex.org") is True
      +
      +    @pytest.mark.asyncio
      +    async def test_named_two_member_dm_is_dm(self):
      +        """A named two-member room in m.direct is a DM (not a room).
      +
      +        Most Matrix clients auto-name DM rooms (e.g. "Alice & Bot"), so the
      +        old `not has_explicit_name` override misclassified them as rooms.
      +        """
      +        self.adapter._joined_rooms = {"!named_dm:ex.org"}
      +        self.adapter._dm_rooms = {"!named_dm:ex.org": True}
      +        self.adapter._client = MagicMock()
      +        self.adapter._client.get_state_event = AsyncMock(
      +            side_effect=lambda room_id, event_type: {"name": "Alice & Bot"}
      +            if event_type == "m.room.name"
      +            else (_ for _ in ()).throw(Exception("no alias"))
      +        )
      +        self.adapter._client.state_store = MagicMock()
      +        self.adapter._client.state_store.get_members = AsyncMock(
      +            return_value=["@bot:ex.org", "@alice:ex.org"]
      +        )
      +
      +        identity = await self.adapter._resolve_room_identity("!named_dm:ex.org")
      +
      +        assert identity.chat_type == "dm"
      +        assert identity.conflict is False
      +        assert identity.joined_member_count == 2
      +        assert await self.adapter._is_dm_room("!named_dm:ex.org") is True
       
           @pytest.mark.asyncio
           async def test_named_room_overrides_stale_dm_cache(self):
      -        """Explicit room names should defeat stale/conflicting m.direct data."""
      +        """Explicit room names defeat stale/conflicting m.direct data when 3+ members."""
               self.adapter._joined_rooms = {"!stale:ex.org"}
               self.adapter._dm_rooms = {"!stale:ex.org": True}
               self.adapter._client = MagicMock()
      @@ -536,12 +564,15 @@ async def test_named_room_overrides_stale_dm_cache(self):
                   else (_ for _ in ()).throw(Exception("no alias"))
               )
               self.adapter._client.state_store = MagicMock()
      -        self.adapter._client.state_store.get_members = AsyncMock(return_value=["@bot:ex.org", "@alice:ex.org"])
      +        self.adapter._client.state_store.get_members = AsyncMock(
      +            return_value=["@bot:ex.org", "@alice:ex.org", "@bob:ex.org"]
      +        )
       
               identity = await self.adapter._resolve_room_identity("!stale:ex.org")
       
               assert identity.chat_type == "room"
               assert identity.conflict is True
      +        assert identity.joined_member_count == 3
               assert await self.adapter._is_dm_room("!stale:ex.org") is False
       
           @pytest.mark.asyncio
      @@ -2057,7 +2088,7 @@ def handle_sync(sync_data):
               fake_client.join_room.assert_awaited_once()
               assert "!room:example.org" in adapter._joined_rooms
               assert len(captured) == 1
      -        assert captured[0].source.chat_type == "group"
      +        assert captured[0].source.chat_type == "dm"
       
           @pytest.mark.asyncio
           async def test_seconds_timestamp_is_not_treated_as_milliseconds(self):
    • tests/gateway/test_matrix_project_context_isolation.py
      @@ -168,6 +168,9 @@ async def test_matrix_project_context_survives_sequential_messages():
       @pytest.mark.asyncio
       async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
           adapter = _make_adapter()
      +    # Override member_count to 3 so the named project room is NOT classified as
      +    # a DM (the DM fix uses member_count <= 2 as the primary DM signal).
      +    adapter._get_room_member_count = AsyncMock(return_value=3)
           adapter._auto_thread = True
           adapter._matrix_session_scope = "auto"
           auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #48554 fix(matrix): use member_count as tiebreaker for named DM room classification · merged via another change → 4aa7933, #51731, #48551
  • #48753 fix(matrix): treat m.direct as authoritative for DM classification (#48551) · done elsewhere → 4aa7933, #48551, #51731
Closed issues · 1
  • Closed#48551[Bug]: Matrix DM rooms with auto-set names incorrectly classified as room, require @mention
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#51731
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.