Skip to content

feat(agents): acknowledge agent mentions so they stop dead-ending - #6126

Open
mfethe1 wants to merge 3 commits into
block:mainfrom
mfethe1:feat/agent-mention-receipts
Open

feat(agents): acknowledge agent mentions so they stop dead-ending#6126
mfethe1 wants to merge 3 commits into
block:mainfrom
mfethe1:feat/agent-mention-receipts

Conversation

@mfethe1

@mfethe1 mfethe1 commented Aug 17, 2026

Copy link
Copy Markdown

What

Tag an agent and nothing happens. Was it picked up and still thinking, is the agent not running, or has it decided not to talk to you? Today all three look identical, because all three are silence — so the message just dead-ends.

This adds NIP-MR, an agent-published receipt (kind 44102), plus a client-side watchdog for the one case no receipt can cover.

Why it dead-ends today

The relay fans out but does not track delivery. dispatch_persistent_event_inner does four things — Redis publish, subscription fan-out, audit enqueue, workflow trigger — and none of them is agent-aware. If the agent's socket is not connected and subscribed at the instant of fan-out, the event is stored for pull queries and pushed to nobody. The ACP harness subscribes with since = now and no replay, so the missed window is never recovered.

The harness also has its own reasons to stay quiet, and they turn out to be the common ones:

What happens What the sender sees
Author gate respond_to defaults to owner-only, so a co-worker mentioning the agent is dropped at a debug! nothing
No rule matched or an evalexpr filter failed closed nothing
Busy under --dedup=drop the event is discarded — no 👀, no steer nothing
Not running never delivered at all nothing

The first row is the single most likely cause in practice, and it is invisible in every direction — including to the agent's owner.

There is already a 👀 reaction on pickup, but it is explicitly cosmetic: a 500 ms timeout with failures swallowed at debug!. On a slow relay a healthy pickup is indistinguishable from a dead one, and it is absent entirely on all the decline paths. It cannot be the signal anyone relies on.

What this does

A receipt for every mention, including the declines. The harness publishes 44102 at the moment it decides what to do — accepted when a turn is coming, declined plus a reason slug when it knowingly will not act. The declined half is the load-bearing one: every silent path above is a case where the harness made a decision and told nobody. Now it says so, and sender-not-allowed is something the sender can act on ("ask the owner to widen respond_to") rather than wait on.

A client watchdog for "nothing is running." No process means no receipt from anyone, ever — absence is the only signal, and only the sender can observe it. The desktop tracks its own agent mentions and, after 30 s, surfaces whatever never answered as an inline footer on the message.

Review notes

Three commits, one per layer — reviewable commit-by-commit:

  1. feat(core) — kind + both relay gates + spec
  2. feat(acp) — the harness publisher
  3. feat(desktop) — client tracking and the footer

(Not opened as a stacked PR: the base branches would have to exist in block/buzz, and these are pushed from a fork.)

A few decisions worth a reviewer's attention:

  • Why a new kind and not 43002. KIND_JOB_ACCEPTED already exists and the desktop fully handles it — but as a visible timeline row, a "Job accepted" feed headline, and a notification sound. Reusing it would mean gutting four working handlers of the 43001→43002 job protocol to stop a lightweight receipt from shouting. It is also un-publishable today (absent from required_scope_for_kind, so the relay rejects it), which is worth knowing independently.
  • Two relay gates, not one. required_scope_for_kind is exhaustive and falls through to Err("restricted: unknown event kind"). requires_h_channel_scope is the separate gate that routes the write through check_channel_membership — without it a non-member could inject receipts into any channel.
  • Not p-gated, on purpose. A receipt is channel-visible like the 👀 it accompanies. Since the relay cannot verify that a key belongs to an agent, the client requires a receipt's author to be a pubkey the message actually mentioned — otherwise any member could publish a receipt to suppress someone else's warning. Forged receipts are inert.
  • Receipts are never acknowledged. A receipt p-tags the author it answers, so by the harness's own "someone tagged me" predicate a receipt is a mention. Without a guard, two sibling agents on wildcard-kind subscriptions would acknowledge each other's acknowledgements forever — a stored event and a model turn per round, no human involved, no terminating condition. Guarded, with a test asserting the p-tag property that makes the loop possible.
  • Consumed in the all-channels subscription. Not the per-channel one, which is torn down on switch — otherwise sending a mention and navigating away discards the receipt and prints "never picked this up" above the reply that did arrive.
  • Registration reads the sent event's own p tags. In a DM every participant is p-tagged, so the harness acks every DM message; registering from explicit @-mentions alone would leave DM prompts — the strictest gate, most likely to dead-end — untracked.

Known limitation

accepted marks the start of a turn, not its completion. An agent that accepts and then crashes, hangs (up to the 2 h wall-clock cap), or has its work superseded publishes no further receipt, so this does not warn about that. Nothing regresses versus today, but please don't read accepted as a delivery guarantee. A terminal dropped receipt plus a completion deadline is the natural follow-up, and is recorded in the spec.

Verification

Windows (MSVC) host.

Gate Result
cargo fmt --all -- --check clean
cargo clippy -p buzz-core -p buzz-relay -p buzz-acp --lib clean
cargo test -p buzz-relay --lib (new kind test) passes
cargo test -p buzz-acp --lib 767 passed / 16 failed
desktop pnpm test 4971 passed / 0 failed
desktop tsc --noEmit clean
desktop biome check clean
desktop pnpm check:px-text clean

On the buzz-acp failures: pre-existing and host-shaped, not from this diff. origin/main at f956e6f gives 752 passed / 24 failed on the same box with none of this branch's code — verified by running the suite against a pristine checkout. Totals reconcile exactly: 776 at base, 783 here, the 7 new tests accounting for the difference. They are subprocess/timing tests in acp::tests and pool::tests; the failure count varies run to run (16–24), which is what flaky timing looks like. This branch touches none of them.

One full desktop run also flaked on useDocumentVisible.test.mjs ("focused polling pauses on blur"), a timing-sensitive test in a file this branch does not touch — 5/5 in isolation, and the re-run was 4971/4971.

Integration suites (Postgres + Redis) and desktop e2e were not run locally; CI covers those.

Provenance

The design was checked against the reverted harness decline gate work — that was a routing gate that decided whether to handle an event and failed closed on broadcast; this only publishes a receipt about a decision the harness already made independently, and changes no dispatch behaviour. It is also distinct from #6090, which signals relay-side fan-out drops under backpressure.

Findings from an adversarial review pass are folded into the three commits, including one blocker (receipts being dropped on channel switch) caught before this was opened.

Michael Feth added 3 commits August 17, 2026 09:34
An @mention of an agent that nothing picks up is today indistinguishable
from one that was picked up and is still being worked on: both are silence.
The relay fans out but does not track delivery, so a mention whose target is
not connected and subscribed at the instant of fan-out reaches nobody, and
nothing is ever published in response.

Define kind 44102, an agent-published receipt for a mention. Tags: h
(channel), e (the mention), p (its author), status of accepted or declined,
and for declined a machine-readable reason slug.

The declined half is the load-bearing one. The paths that dead-end a mention
today are precisely the ones where the harness knowingly decided not to act:
the author is outside respond_to (which defaults to owner-only, so any
co-worker mention lands here), no rule matched, a filter failed closed, or
it is busy and configured to drop. Each exits at a debug! log. A declined
receipt turns those into an explicit, explained outcome the sender can act
on.

Two relay gates are required, not one. required_scope_for_kind is an
exhaustive match whose fallthrough is Err("restricted: unknown event kind"),
so without an arm the relay rejects the publish outright -- this is why the
pre-existing kind 43002 KIND_JOB_ACCEPTED has never been publishable, and
why reusing it was rejected in favour of a fresh kind. requires_h_channel_scope
is the gate that routes the write through check_channel_membership; omitting
it would let a non-member inject receipts into any channel.

The kind is deliberately not p-gated: a receipt is channel-visible like the
eyes reaction it accompanies, so co-members and sibling agents can see that a
mention was received. Because the relay cannot verify that a signing key
belongs to an agent, clients must ignore receipts whose author is not a
pubkey the acknowledged message actually mentioned; NIP-MR.md states this as
a requirement.

44102 also joins the channel live-subscription kind set so clients receive
receipts, and the relay metrics bucket so ack volume is not lumped in with
unregistered traffic.

Spec: docs/nips/NIP-MR.md. Named MR (Mention Receipt) rather than MA, which
sat one transposition away from the existing NIP-AM.md in the same directory.

Signed-off-by: Michael Feth <michael@jira-flow.com>
Stacked on the kind-44102 definition.

The harness had exactly one immediate signal that a mention was received: a
fire-and-forget eyes reaction with a 500ms timeout whose failures are
swallowed at debug!. Its own docs call it cosmetic. That makes it unusable as
something a sender can rely on -- on a slow relay a perfectly healthy pickup
is indistinguishable from an agent that is not running at all.

Worse, three paths produced no signal whatsoever, and they are exactly the
paths where the harness knowingly decided not to act:

  - the inbound author gate drops the event because the author is outside
    respond_to, which defaults to owner-only, so any co-worker mentioning the
    agent is dropped here. This is the most likely cause of a dead-ended
    mention in practice and it is invisible in every direction.
  - no rule matched, or a filter expression failed closed.
  - DedupMode::Drop discarded the event because the channel is already in
    flight. That path posts no eyes reaction and fires no steer, making it
    the most completely silent outcome in the harness.

Publish a kind 44102 receipt at each decision point, carrying accepted or
declined plus a reason slug. A declined receipt is not a failure notice; it
is the agent saying "I received this and I am not going to answer, here is
why", which is what lets the sender act -- ask the owner to widen respond_to
-- instead of waiting on nothing.

Gated on event_mentions(): a receipt fires only for events that actually
p-tag this agent, never for the agent's own output, and never for kind 44102
itself. That last guard matters: a receipt p-tags the author it answers, so
a receipt is itself a mention of that agent, and two sibling agents on
wildcard-kind subscriptions would otherwise acknowledge each other's
acknowledgements indefinitely -- each round a stored event and a model turn,
no human involved, no terminating condition. A test asserts the p-tag
property that makes the loop possible so the guard cannot be quietly dropped.

The mention predicate is extracted from match_event's own require_mention
check rather than rewritten, so an agent acknowledges exactly the events a
require_mention rule treats as mentions; the existing
test_match_event_require_mention covers the refactor.

Publishing is spawned, never awaited, so a receipt cannot add latency to
dispatch or hold the event loop behind a slow relay. It gets a 5s timeout
rather than the reaction's 500ms and warns rather than debugs on failure: a
dropped receipt recreates the dead-end it exists to prevent.

Event construction is split into build_mention_ack_event so the tag shape --
which the desktop matches on -- is testable without a relay.

Note that accepted marks the start of a turn, not its completion: an agent
that accepts and then crashes or hangs publishes no further receipt. That
limitation is recorded in NIP-MR.md and is left to a follow-up.

Verified: this adds 7 tests, all passing. cargo test -p buzz-acp --lib is
752 passed / 24 failed at origin/main and 767 passed / 16 failed here (776
and 783 total respectively -- the 7 new tests account for the difference).
The failures are a flaky subprocess-and-timing set in acp::tests and
pool::tests on this Windows box; the count varies run to run, they reproduce
against a pristine origin/main checkout, and this commit touches none of them.

Signed-off-by: Michael Feth <michael@jira-flow.com>
Stacked on the harness receipt publisher.

The harness now publishes a receipt for every mention it sees, which covers
every case where an agent is running. It cannot cover the case where nothing
is running: the relay fans out without tracking delivery, so if the agent is
not connected and subscribed at the instant of fan-out the mention reaches
nobody and no receipt is ever published by anyone. Silence is the only
observable, and only the sending client is in a position to notice it.

Track every agent mention on send, resolve it against incoming kind 44102
receipts, and after 30s report whatever never answered. The message then
carries an inline footer naming what happened -- "Ada is not accepting
messages from you", "Ada never picked this up, it may be offline" -- instead
of sitting there looking delivered.

Details worth calling out:

Receipts are consumed in useLiveChannelUpdates, which subscribes across every
joined channel, not in the per-channel subscription -- that one covers only
the channel currently open and is torn down on switch, so consuming there
would discard any receipt arriving after the user navigates away and then
claim, 30s later, that the agent never picked the message up. Directly above
the reply it did send.

Registration reads the sent event's own p tags rather than the composer's
explicit mentions. Those are the tags an agent actually matches on, and in a
DM they include every participant even when the text contains no @ -- so
explicit mentions alone would leave DM prompts untracked, which is the case
with the strictest gate and the most likely to dead-end.

Only agent pubkeys are tracked, via the known-agent set. Mentioning a
colleague must never produce "they did not respond within 30 seconds".

Registered in onSuccess rather than onMutate because a receipt references the
real event id the agent saw, and the optimistic id is local-only.

A receipt resolves an entry only when its author is a pubkey the message
actually mentioned. Any member can publish a well-formed 44102 -- the relay
cannot check agent-ness -- so without that check a third party could suppress
someone else's warning. Both pubkeys are normalised, or the check fails
silently and genuine receipts look forged.

A reply clears tracking outright, on any referenced e tag rather than only
the thread parent, because the harness anchors its reply to the thread root.

A partial accept suppresses the warning for silent siblings: if a team
mention reaches four agents and one takes it, the mention is answered and
warning about the other three would be actively misleading.

The 30s window is deliberately generous. A managed agent may still be booting
when the mention is sent -- the desktop launches it as part of the send flow
-- and a premature "nobody picked this up" on an agent that was merely
starting is worse than a late one.

Rendered through MessageTimeline's messageFooters slot, which existed fully
wired with no caller. That keeps the notice clear of MessageRow's
hand-written memo comparator, where a new prop would have been silently
ignored (AGENTS.md gotcha 7). Store snapshots are content-cached per channel
for the same reason: a getter that rebuilds its object or array would
re-render forever through useSyncExternalStore.

resetPendingMentionAckStore is wired into resetCommunityState per the
community-switching contract, and deliberately does not clear its listener
set, which would orphan any subscriber outliving the switch.

Known limitation, stated in NIP-MR.md: accepted marks the start of a turn,
not its completion. An agent that accepts and then crashes or hangs publishes
no further receipt, so this does not warn about it. Nothing regresses versus
today, but reviewers should not read accepted as a completion guarantee.

Verified: 4971 desktop tests pass (17 in the new store), tsc clean, biome
clean, check:px-text clean.

Signed-off-by: Michael Feth <michael@jira-flow.com>
@mfethe1
mfethe1 requested a review from a team as a code owner August 17, 2026 13:42

@mfethe1 mfethe1 left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review — Ernie (Tier 4). Full diff read at head 76046915 vs base f956e6f (15 files, 1640-line diff + docs/nips/NIP-MR.md). No reviews on file prior; this is first-pass, not a gate ruling — verifier/re-review sits with Winnie (mention-hygiene invariant).

Verdict: technically sound, no correctness blockers. Each "Review notes" claim checked out in the diff:

  • Kind + two gates44102 registered in kind.rs (ALL_KINDS, status/reason consts) and event.rs label range 44100..=44102. required_scope_for_kind → MessagesWrite and requires_h_channel_scope → true are correctly split (the exhaustive-match fallthrough and the membership-routing gate are handled distinctly); the ingest test asserts all four invariants (scope, h-scope, not global-only, not p-gated).
  • Not p-gated — confirmed. The compensating "client verifies author ∈ mentioned pubkeys" rule is carried in pendingMentionAckStore.applyMentionAck (both sides normalized) and documented in NIP-MR.md.
  • Loop guardack_mention returns early on kind 44102 and on self-author; the pool test asserts the p-tag property that makes the infinite-ack loop possible. Good.
  • All-channels consumption — acks are handled in useLiveChannelUpdates, not the per-channel sub (which is torn down on switch), and applied idempotently. Matches the channel-switch concern from the body.

Findings (nothing blocking):

  1. [P3 · compliance — fix before merge] All three commits (902b8e53, e219f497, 76046915) carry Signed-off-by: Michael Feth only — no human Co-authored-by. These read as agent-authored (committed under mfethe1 per the workspace git-identity convention, commit/PR text is agent-drafted), so the Co-authored-by: Michael Feth <michael@jira-flow.com> trailer is required, not redundant. Add it via rebase/rewrite, ordered before Signed-off-by. (Escape hatch: if Michael confirms he hand-wrote these commits himself, the trailer is moot.)

  2. [FYI · no action] no-matching-rule slug is overloaded — it covers both "no rule matched" and "filter expression failed closed". Documented in the body and still actionable in describeReason copy. Flagging only so the overloading reads as conscious, not accidental.

Evidence boundary: I reviewed the diff and metadata only; I did not run the Rust/desktop suites locally at 76046915. Body-reported Rust unit is 767/16 (the 16 failures reproducible pre-existing at origin/main f956e6f); desktop 4971/0 with one useDocumentVisible flake isolated. Postgres+Redis integration and desktop e2e are unrun and need CI. Domain re-review sits with Winnie.

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.

1 participant