MessageDeduplicator TTL expiry and slash-command exemption bugs

NousResearch/hermes-agent · cluster 10306 · gateway

Data as of

3 open items. Fix merged in #10509. Check it covers your case.

The short answer

2open issues
1open PR
13items grouped
Known?
Yes. 13 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #10509. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
2 issues and 1 PR.

These issues and fixes address two related deduplication problems: TTL-based expiry in MessageDeduplicator only triggering on cache overflow rather than on lookup, and Weixin/WeChat content deduplication incorrectly suppressing legitimate repeated messages and slash commands like /approve.

Open issues2

  • Issue#29779WeChat adapter content dedup silently drops repeated commands, breaking /approve workflow
  • Issue#36750[Bug]: Weixin content dedup can drop legitimate repeated messages after the previous turn has completed

Open PRs1

  • PR#36758fix(weixin): clear content dedup key after text-batch dispatch

Fixes on record

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

  • #10509 fix: enforce TTL in MessageDeduplicator + use yaml for gateway --config

    merged to main

    files: gateway/platforms/helpers.py, gateway/run.py, tests/gateway/test_message_deduplicator.py

    Show the diff
    • gateway/platforms/helpers.py
      @@ -49,7 +49,10 @@ def is_duplicate(self, msg_id: str) -> bool:
                   return False
               now = time.time()
               if msg_id in self._seen:
      -            return True
      +            if now - self._seen[msg_id] < self._ttl:
      +                return True
      +            # Entry has expired — remove it and treat as new
      +            del self._seen[msg_id]
               self._seen[msg_id] = now
               if len(self._seen) > self._max_size:
                   cutoff = now - self._ttl
    • gateway/run.py
      @@ -9725,9 +9725,9 @@ def main():
           
           config = None
           if args.config:
      -        import json
      +        import yaml
               with open(args.config, encoding="utf-8") as f:
      -            data = json.load(f)
      +            data = yaml.safe_load(f)
                   config = GatewayConfig.from_dict(data)
           
           # Run the gateway - exit with code 1 if no platforms connected,
    • tests/gateway/test_message_deduplicator.py
      @@ -0,0 +1,89 @@
      +"""Tests for MessageDeduplicator TTL enforcement (#10306).
      +
      +Previously, is_duplicate() returned True for any previously seen ID without
      +checking its age — expired entries were only purged when cache size exceeded
      +max_size.  Normal workloads never overflowed, so messages stayed "duplicate"
      +forever.
      +
      +The fix checks TTL at query time: if the entry's timestamp plus TTL is in
      +the past, the entry is treated as expired and the message is allowed through.
      +"""
      +
      +import time
      +from unittest.mock import patch
      +
      +from gateway.platforms.helpers import MessageDeduplicator
      +
      +
      +class TestMessageDeduplicatorTTL:
      +    """TTL-based expiration must work regardless of cache size."""
      +
      +    def test_duplicate_within_ttl(self):
      +        """Same message within TTL window is duplicate."""
      +        dedup = MessageDeduplicator(ttl_seconds=60)
      +        assert dedup.is_duplicate("msg-1") is False
      +        assert dedup.is_duplicate("msg-1") is True
      +
      +    def test_not_duplicate_after_ttl_expires(self):
      +        """Same message AFTER TTL expires should NOT be duplicate."""
      +        dedup = MessageDeduplicator(ttl_seconds=5)
      +        assert dedup.is_duplicate("msg-1") is False
      +
      +        # Fast-forward time past TTL
      +        dedup._seen["msg-1"] = time.time() - 10  # 10s ago, TTL is 5s
      +        assert dedup.is_duplicate("msg-1") is False, \
      +            "Expired entry should not be treated as duplicate"
      +
      +    def test_expired_entry_gets_refreshed(self):
      +        """After an expired entry is allowed through, it should be re-tracked."""
      +        dedup = MessageDeduplicator(ttl_seconds=5)
      +        assert dedup.is_duplicate("msg-1") is False
      +
      +        # Expire the entry
      +        dedup._seen["msg-1"] = time.time() - 10
      +
      +        # Should be allowed through (expired)
      +        assert dedup.is_duplicate("msg-1") is False
      +        # Now should be duplicate again (freshly tracked)
      +        assert dedup.is_duplicate("msg-1") is True
      +
      +    def test_different_messages_not_confused(self):
      +        """Different message IDs are independent."""
      +        dedup = MessageDeduplicator(ttl_seconds=60)
      +        assert dedup.is_duplicate("msg-1") is False
      +        assert dedup.is_duplicate("msg-2") is False
      +        assert dedup.is_duplicate("msg-1") is True
      +        assert dedup.is_duplicate("msg-2") is True
      +
      +    def test_empty_id_never_duplicate(self):
      +        """Empty/None message IDs are never treated as duplicate."""
      +        dedup = MessageDeduplicator(ttl_seconds=60)
      +        assert dedup.is_duplicate("") is False
      +        assert dedup.is_duplicate("") is False
      +
      +    def test_max_size_eviction_prunes_expired(self):
      +        """Cache pruning on overflow removes expired entries."""
      +        dedup = MessageDeduplicator(max_size=5, ttl_seconds=60)
      +        # Add 6 entries, with the first 3 expired
      +        now = time.time()
      +        for i in range(3):
      +            dedup._seen[f"old-{i}"] = now - 120  # expired (2 min ago, TTL 60s)
      +        for i in range(3):
      +            dedup.is_duplicate(f"new-{i}")
      +        # Now we have 6 entries. Next insert triggers pruning.
      +        dedup.is_duplicate("trigger")
      +        # The 3 expired entries should be gone, leaving 4 fresh ones
      +        assert len(dedup._seen) == 4
      +        assert "old-0" not in dedup._seen
      +        assert "new-0" in dedup._seen
      +
      +    def test_ttl_zero_means_no_dedup(self):
      +        """With TTL=0, all entries expire immediately."""
      +        dedup = MessageDeduplicator(ttl_seconds=0)
      +        assert dedup.is_duplicate("msg-1") is False
      +        # Entry was just added at time.time(), and TTL is 0,
      +        # so now - seen_time >= 0 = ttl, meaning it's expired
      +        # But time.time() might be the exact same float, so
      +        # the check is `now - ts < ttl` which is `0 < 0` = False
      +        # This means TTL=0 effectively disables dedup
      +        assert dedup.is_duplicate("msg-1") is False

7 PRs closed without merging

From the close comments: duplicate 2 · superseded by another PR 2 · done elsewhere 1 · stale or abandoned 1 · other 1

  • #10312 fix(dedup): check TTL expiry before returning duplicate status · duplicate → #10509
  • #10345 fix(gateway): purge MessageDeduplicator entries by TTL on every lookup · done elsewhere → #10509, #13829
  • #29787 fix(gateway): exempt slash commands from weixin content-fingerprint dedup (#29779) · stale or abandoned
  • #29855 fix(weixin): skip content dedup for slash commands like /approve · superseded by another PR → #29787, #29779
  • #33218 fix(weixin): skip content dedup for approval commands · superseded by another PR → #29787
Show 2 more
  • #36707 fix(weixin): exempt slash commands from content-fingerprint dedup · duplicate → #35434, #29787, #29855, #29779
  • #129536 fix(weixin): preserve intentional repeated slash commands · other
Closed issues · 2
  • Closed#10306[Bug]: MessageDeduplicator TTL never expires unless cache exceeds max_size
  • Closed#13829MessageDeduplicator TTL never expires entries unless the cache overflows
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.13: 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
#10509
Closed issues
2
Attempts
7 PRs closed without merging
Fix link checked
15 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.