Summary
Two or more agents owned by the same human, both on BUZZ_ACP_SUBSCRIBE=all, will storm each
other indefinitely without a single chat message being sent. Each agent wakes on the other's
👀/💬 pickup reactions, which are harness bookkeeping rather than user intent.
This is a sibling of #3649 but it is not fixed by #4040. That PR guards ephemeral kinds
(20000-29999). Reactions are kind 7 and their cleanup deletions are kind 5: regular,
stored, and still delivered to a wildcard subscriber. On our relay the reaction loop was roughly
20x larger than the typing-indicator burn that #3649 describes, so subscribe=all will look
fixed after #4040 while this remains open.
It also does not require respond_to=anyone. See "Why default settings are enough" below.
Mechanism
Every event an agent picks up generates four more events of its own:
| step |
source |
kind |
reaction_add(eid, "👀") at queue-push |
lib.rs:2212 |
7 |
reaction_add(eid, "💬") before the prompt fires |
pool.rs:1881 |
7 |
ReactionGuard removes 👀 on turn end |
pool.rs:3380 |
5 |
ReactionGuard removes 💬 on turn end |
pool.rs:3380 |
5 |
All four are published into the same channel. With subscribe=all and no BUZZ_ACP_KINDS
override, the subscription is a wildcard in two independent places:
config.rs resolve_channel_filters / resolve_dynamic_channel_filter: SubscribeMode::All
yields ChannelFilter { kinds: config.kinds_override.clone(), require_mention: false }, which
is None when unset;
relay.rs send_subscribe: kinds is omitted from the REQ entirely when the filter is
None, so the relay pushes every kind in the channel;
lib.rs rules construction: SubscribeMode::All yields
SubscriptionRule { kinds: kinds_override.unwrap_or_default(), .. } = an empty vec, and
filter.rs match_event documents empty as wildcard
(if !rule.kinds.is_empty() && !rule.kinds.contains(..)).
So with N wildcard agents in a channel, one real message produces 4N bookkeeping events, each of
which wakes the other N-1 agents, each of those producing 4 more. ignore_self does not help:
it only suppresses an agent reacting to its own events, and A -> B -> A is exactly the case it
misses. There is no reply-depth counter, hop limit, or cooldown anywhere in the path.
Why default settings are enough to trigger it
respond_to=owner-only is the default and does not prevent this. The inbound author gate in
lib.rs admits same-owner siblings in both OwnerOnly and Allowlist modes, by design:
Both OwnerOnly and Allowlist accept events from "siblings" - pubkeys whose
agent_owner_pubkey matches this agent's owner (e.g. other bots launched by the same human).
So any human running two agents on subscribe=all hits this on stock config. Given #3204 /
#3277 / #2508 make subscribe=all the standard workaround for reaching an agent on another
machine, that combination is not exotic.
Measured impact
Self-hosted relay (compose stack from deploy/compose), buzz pinned at d40a3329, three agents
owned by one human: buzz-agent 0.1.0, OpenCode 1.18.10, and claude-agent-acp 0.64.0. All
three were on subscribe=all with no BUZZ_ACP_KINDS.
Event counts, straight from the relay's postgres:
| metric |
count |
| kind 7 (reactions) stored |
25,717 |
| kind 9 (actual chat messages) stored |
304 |
reactions whose e target is another kind 7 |
22,373 |
reactions whose e target is a kind 5 deletion |
2,967 |
reactions whose e target is a real kind 9 message |
338 |
So 87% of all reactions were reactions to reactions. Per author, all three agents, none of it
human:
| author |
👀 |
💬 |
| agent A |
8,971 |
3,210 |
| agent B |
8,586 |
2,982 |
| agent C |
1,010 |
954 |
| the human owner |
1 |
2 |
Peak was 20,113 kind-5/7 events in a single hour. Agent-side symptoms: 5,650 HTTP 429s in 30
minutes on one agent, 729 pool_exhausted on another, and the 500-message queue cap hit.
Incidentally confirming #3649 from the same deploy: the relay rejected 1,063 reaction
publishes with invalid: reaction target event not found, which is the fingerprint of an agent
trying to 👀 an ephemeral typing/presence event that was never stored.
Reproduction
- Two agents, same owner, both members of one channel.
- Both on
BUZZ_ACP_SUBSCRIBE=all with BUZZ_ACP_KINDS unset. respond_to can stay at its
owner-only default.
- Post one message in the channel.
- Both agents 👀 it, then 👀 each other's 👀, and it does not converge.
Suggested fix
Three options, roughly in order of how well they preserve intent:
- Mark harness-generated reactions. 👀/💬 are bookkeeping, not user intent, but are
indistinguishable from a human reaction on the wire. A tag on the reaction event would let
match_event drop them while still waking on genuine human reactions.
- Skip reactions authored by sibling agents. Narrower, no wire format change, but it also
suppresses deliberate agent-to-agent reactions.
- Give
SubscribeMode::All a curated default kind list when kinds_override is None,
the same shape mentions mode already has.
On option 3, one correction to the suggestion in #3649: that issue proposes a default allowlist
"mirroring the mentions-mode one plus reactions and canvas", and the workaround it recommends
is BUZZ_ACP_KINDS=9,40002,40003,5,7,1059,40100,40007,46010,46020. Including 5 and 7 is
exactly what detonates in a multi-agent channel. That allowlist is safe for a single-agent
deploy, which is presumably where it was validated, but it should carry a warning or drop those
two kinds.
Workaround
Same allowlist, minus the deletions and reactions:
BUZZ_ACP_KINDS=9,40002,40003,1059,40100,40007,46010,46020
Applied to the one agent we keep on subscribe=all; the rest run subscribe=mentions, which is
already narrow by construction. This took the relay from 20,113 bookkeeping events an hour to
zero reaction-on-reaction traffic. Note this is a strict superset of what #4040 fixes, so it
remains necessary after that PR lands.
Summary
Two or more agents owned by the same human, both on
BUZZ_ACP_SUBSCRIBE=all, will storm eachother indefinitely without a single chat message being sent. Each agent wakes on the other's
👀/💬 pickup reactions, which are harness bookkeeping rather than user intent.
This is a sibling of #3649 but it is not fixed by #4040. That PR guards ephemeral kinds
(20000-29999). Reactions are kind 7 and their cleanup deletions are kind 5: regular,
stored, and still delivered to a wildcard subscriber. On our relay the reaction loop was roughly
20x larger than the typing-indicator burn that #3649 describes, so
subscribe=allwill lookfixed after #4040 while this remains open.
It also does not require
respond_to=anyone. See "Why default settings are enough" below.Mechanism
Every event an agent picks up generates four more events of its own:
reaction_add(eid, "👀")at queue-pushlib.rs:2212reaction_add(eid, "💬")before the prompt firespool.rs:1881ReactionGuardremoves 👀 on turn endpool.rs:3380ReactionGuardremoves 💬 on turn endpool.rs:3380All four are published into the same channel. With
subscribe=alland noBUZZ_ACP_KINDSoverride, the subscription is a wildcard in two independent places:
config.rsresolve_channel_filters/resolve_dynamic_channel_filter:SubscribeMode::Allyields
ChannelFilter { kinds: config.kinds_override.clone(), require_mention: false }, whichis
Nonewhen unset;relay.rssend_subscribe:kindsis omitted from theREQentirely when the filter isNone, so the relay pushes every kind in the channel;lib.rsrules construction:SubscribeMode::AllyieldsSubscriptionRule { kinds: kinds_override.unwrap_or_default(), .. }= an empty vec, andfilter.rsmatch_eventdocuments empty as wildcard(
if !rule.kinds.is_empty() && !rule.kinds.contains(..)).So with N wildcard agents in a channel, one real message produces 4N bookkeeping events, each of
which wakes the other N-1 agents, each of those producing 4 more.
ignore_selfdoes not help:it only suppresses an agent reacting to its own events, and A -> B -> A is exactly the case it
misses. There is no reply-depth counter, hop limit, or cooldown anywhere in the path.
Why default settings are enough to trigger it
respond_to=owner-onlyis the default and does not prevent this. The inbound author gate inlib.rsadmits same-owner siblings in bothOwnerOnlyandAllowlistmodes, by design:So any human running two agents on
subscribe=allhits this on stock config. Given #3204 /#3277 / #2508 make
subscribe=allthe standard workaround for reaching an agent on anothermachine, that combination is not exotic.
Measured impact
Self-hosted relay (compose stack from
deploy/compose), buzz pinned atd40a3329, three agentsowned by one human:
buzz-agent0.1.0, OpenCode 1.18.10, andclaude-agent-acp0.64.0. Allthree were on
subscribe=allwith noBUZZ_ACP_KINDS.Event counts, straight from the relay's postgres:
etarget is another kind 7etarget is a kind 5 deletionetarget is a real kind 9 messageSo 87% of all reactions were reactions to reactions. Per author, all three agents, none of it
human:
Peak was 20,113 kind-5/7 events in a single hour. Agent-side symptoms: 5,650 HTTP 429s in 30
minutes on one agent, 729
pool_exhaustedon another, and the 500-message queue cap hit.Incidentally confirming #3649 from the same deploy: the relay rejected 1,063 reaction
publishes with
invalid: reaction target event not found, which is the fingerprint of an agenttrying to 👀 an ephemeral typing/presence event that was never stored.
Reproduction
BUZZ_ACP_SUBSCRIBE=allwithBUZZ_ACP_KINDSunset.respond_tocan stay at itsowner-onlydefault.Suggested fix
Three options, roughly in order of how well they preserve intent:
indistinguishable from a human reaction on the wire. A tag on the reaction event would let
match_eventdrop them while still waking on genuine human reactions.suppresses deliberate agent-to-agent reactions.
SubscribeMode::Alla curated default kind list whenkinds_overrideisNone,the same shape mentions mode already has.
On option 3, one correction to the suggestion in #3649: that issue proposes a default allowlist
"mirroring the mentions-mode one plus reactions and canvas", and the workaround it recommends
is
BUZZ_ACP_KINDS=9,40002,40003,5,7,1059,40100,40007,46010,46020. Including5and7isexactly what detonates in a multi-agent channel. That allowlist is safe for a single-agent
deploy, which is presumably where it was validated, but it should carry a warning or drop those
two kinds.
Workaround
Same allowlist, minus the deletions and reactions:
Applied to the one agent we keep on
subscribe=all; the rest runsubscribe=mentions, which isalready narrow by construction. This took the relay from 20,113 bookkeeping events an hour to
zero reaction-on-reaction traffic. Note this is a strict superset of what #4040 fixes, so it
remains necessary after that PR lands.