feat: reply-decision gate to stop infinite loops in group chats (#36)#53
Merged
Conversation
5cbd177 to
52347ba
Compare
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.
52347ba to
8a832e5
Compare
…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.
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.
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:
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.
max_replies_per_window/window_seconds) that guarantees a runaway loop terminates even if the gatekeeps 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
chat_id != user_id(Telegram private chats and WhatsApp DMs reuse the user's id as the chat id).
message.
Config (
reply_decision, off by default)Tests
tests/test_reply_decision.pycovers 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.mdxsection +config.yml.example.