Skip to content

feat: reply-decision gate to stop infinite loops in group chats (#36)#53

Merged
mattmezza merged 6 commits into
mainfrom
feat/reply-decision
Jun 29, 2026
Merged

feat: reply-decision gate to stop infinite loops in group chats (#36)#53
mattmezza merged 6 commits into
mainfrom
feat/reply-decision

Conversation

@mattmezza

Copy link
Copy Markdown
Owner

Closes #36.

Problem

In a shared group chat that mixes multiple bots and people, an "always reply"
agent can get caught in an infinite reaction loop — bot A replies to bot B
replies to bot A… — and replies to messages plainly aimed at someone else.

What this does

Adds an opt-in reply-decision layer so the agent decides, per message,
whether a reply is warranted at all. Two independent mechanisms:

  1. Smart gate (core/reply_decision.py) — a cheap one-shot LLM call
    (REPLY/SKIP) that stays quiet for messages addressed to another bot,
    messages in a bot-to-bot loop, or messages it can't usefully add to. It
    fails open (replies) on any error, so a classifier hiccup never drops a
    real user message.
  2. Hard rate cap — a per-chat backstop (max_replies_per_window /
    window_seconds) that guarantees a runaway loop terminates even if the gate
    keeps voting "reply". An LLM-only gate can't make that guarantee on its own.

Where it hooks in

AgentCore.process() is the single chokepoint every channel (Telegram,
WhatsApp, voice, REPL, scheduler) funnels through, so one gate covers them all.
It runs right after persona resolution and returns an empty response on skip, so
no expensive inference or tool loop runs for a suppressed message.

Scope guards

  • Off by default — 1:1 chats always reply and the gate costs one extra call.
  • Group-only by default — only gates chats where chat_id != user_id
    (Telegram private chats and WhatsApp DMs reuse the user's id as the chat id).
  • Never gates scheduler/system turns — proactive jobs are untouched.
  • WhatsApp now stays silent on an empty response instead of sending a blank
    message.

Config (reply_decision, off by default)

reply_decision:
  enabled: false
  model: "claude-haiku-4-5"
  group_only: true
  max_replies_per_window: 6
  window_seconds: 120

Tests

tests/test_reply_decision.py covers the classifier (REPLY/SKIP/fail-open),
the group-vs-DM heuristic, the rate-cap backstop (incl. window expiry), and the
end-to-end suppression path through process(). Full suite green
(uv run pytest).

Docs: README feature bullet + configuration.mdx section + config.yml.example.

@mattmezza mattmezza force-pushed the feat/reply-decision branch from 5cbd177 to 52347ba Compare June 29, 2026 11:59
Add an opt-in decision layer so the agent stays quiet on messages that
don't warrant a reply in shared/group chats, preventing infinite bot-to-bot
reaction loops:

- core/reply_decision.py: one-shot LLM classifier (REPLY/SKIP), fails open
- AgentCore.process: gate after persona resolution; group-only by default
  (chat_id != user_id heuristic), skips scheduler/system turns
- hard per-chat rate cap backstops the LLM gate so a runaway loop always
  terminates even if the gate keeps voting reply
- ReplyDecisionConfig (off by default)
- whatsapp: stay silent on empty response instead of sending a blank message
- tests
README feature bullet, config.yml.example block, and configuration.mdx
section + full-example entry.
…36)

Adversarial-review fixes:

- Rate cap is now reserve-before-await: _reserve_reply atomically claims a
  slot before the should_reply LLM round-trip, so concurrent messages in the
  same chat see the reservation and the cap holds under a bursty bot-to-bot
  loop (closes a check-then-act race that let a burst sail past the cap). A
  SKIP releases its slot via _release_reply so quiet decisions don't burn a
  busy human group's budget.
- Run the reply gate before goal decomposition, so a suppressed message costs
  only the one cheap gate call instead of a wasted classify/decompose pass.
- Self-check now drives the real should_reply (stub LLM) instead of a parse copy.
- Tests drive process() for the SKIP/REPLY/DM/rate-cap paths and assert slot
  reserve/release, instead of only poking helpers.
@mattmezza mattmezza force-pushed the feat/reply-decision branch from 52347ba to 8a832e5 Compare June 29, 2026 12:44
…elf-check (#36)

- Match the project's cheap-background-LLM convention (deepseek-v4-flash),
  same as goal_decomposition / task_reflection / subagent_summary, instead of
  anthropic/claude-haiku-4-5.
- Remove the __main__ self-check: tests/test_reply_decision.py already covers
  should_reply end to end, so the inline copy was redundant.
Add a Reply Decision card alongside Goal Decomposition / Task Reflection:
enable toggle, provider/model + thinking level, group-only toggle, and the
rate-cap knobs (max replies per window / window seconds). Saved via the
generic /config PATCH and hot-applied to the running agent. Render test added.
@mattmezza mattmezza merged commit c667323 into main Jun 29, 2026
1 check passed
@mattmezza mattmezza deleted the feat/reply-decision branch June 29, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent should decide whether to reply or not (avoid infinite reaction loops in multi-bot chats)

1 participant