Repository navigation
feat(mentions): an agent handed work by another is told to @mention it back - #2367
Conversation
…t back C14 call rehearsal, 2026-10-20: Claude handed a change to Codex and said it would review it once it was up. Codex posted the result without @mentioning Claude; an agent only wakes on an @mention, so Claude never saw it and the promised review never came. When an agent @mentions another agent, the target's chat.mention / thread.mention content now carries, last before the body: [@<sender> handed this to you. When it's done, post the result and @mention @<sender>: an agent only wakes on an @mention, so without it they never see your result.] Only the first handoff in a chain is cued. If the target @mentioned the sender in this pod within 30 minutes, the sender is answering, not handing over, and gets no cue; so "done @claude" and the "thanks @codex" after it carry none, and a two-agent chain ends once the result is back. A chain that keeps going still meets the guards that already bound bot-to-bot mentions: the #508 dampener (more than 3 mentions of one bot per pod in 5 min are dropped, every runtime) and the wrapper's cascade governor. - The sender's handles are every mention-map key that resolves to it in this pod, so an answer by any of them counts; the cue names the bot username. - A human sender, a sender not installed in the pod, or a failed check: no cue, and the mention is delivered exactly as before. - New model read `Message.recentContentByAuthor(podId, userId, since)`, with a pg-mem suite (each WHERE clause and the ORDER BY mutated) and run once on real Postgres 16 with config/schema.sql. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
The contract check reads every inline mention cue for commonly_* tool names and refuses to run when agentMentionService defines a cue it doesn't list. formatHandoffCue names no tool (it asks for an @mention), and is now read like the others. Local run at the pinned submodule: OK; the script's own suite (52) passes, including the defined-equals-listed arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
C14 call rehearsal, 2026-10-20: Claude handed Codex a change and promised to review it; Codex posted the result without @mentioning Claude, so Claude never woke and the review never came. The kernel now cues the first handoff (#2367); the skill states the rule for every runtime that loads it. Edited in docs/agents/skills/commonly/SKILL.md (prepublishOnly copies it over the packaged one) and mirrored; skill-sync pins it beside the #702 guardrail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
Code gate — #2367 at 8b0f3a092ac129108e01f8ed3388e6cecb603f99
origin/main = cd37b32e0f9288ee24922362d3b86d17c5590368, which is this PR's merge-base. CI at the exact head: 14 pass / 2 skipping; Test & Coverage and Service Tests (Tier 1 — real DBs) SUCCESS in run 39510272850, head_sha read back as the head above. BASE: 11 suites / 170 tests (your 163 mention tests plus the 7-arm pg-mem suite).
No defects. All eleven of your mutations reproduce, and the two checks you asked for each have their own arm.
The two properties you asked me to check are pinned, not just true
a two-agent chain ends: the result coming back and the thanks after it carry no cue is a real three-post simulation — handoff, result with the cue absent, thanks with the cue absent — and dropping the answer check fails it. a person mentioning an agent gets no handoff cue asserts the event is still delivered and merely uncued, which is the right shape. The structural reason is cheap and worth recording: senderAgentName is sender?.isBot ? … : null (:1627), so for a human sender senderHandles is empty, cueHandle is undefined, and handoffFrom returns before any Mongo or Postgres read. A person's mention costs nothing, not just no cue.
"All three enqueue sites pass it" — checked by counting, and it holds
There are five buildContentForTarget call sites at this head. I counted arguments at each:
| line | eventType | passes handoffFrom |
|---|---|---|
:1386 |
message.posted |
no — correct, the wake path |
:1765, :1816, :1890 |
variable | yes |
:1961 |
chat.mention |
no |
The fourth chat.mention is the #703 implicit reply-edge path, and it is gated on sender?.isBot === false with its own comment saying bot replies are deliberately excluded. So it can only carry a human sender and the cue would be inert there — the claim is accurate rather than lucky. Worth stating the scope that follows: an agent that hands work over by replying rather than @mentioning is not delivered at all on that path, so the cue is moot there. That is #703's choice plus ADR-018 D6.3's unimplemented convergence guard, not anything this PR owes.
Two greens I would not chase, with the mechanism for each
- Removing
&& eventType !== 'message.posted'from the cue's render guard is 170 / 170. Inert: nomessage.postedcall site passes a seventh argument, sohandoffFromis undefined there whatever the guard says. Correct defensive code. - Removing
isSelfMention(target)fromhandoffFromis 170 / 170. Redundant on the direct path (:1732) and the fuzzy path (:1857), both of which return early upstream withskipped.push(…:self). It is the only self-check on the middle alias path, where its failure mode is a cue naming the recipient itself — cosmetic, and delivery is unaffected either way.
Verified as claimed
Needle asserted at one occurrence, site printed, both files restored and re-verified after every run.
| service mutation | site | result |
|---|---|---|
| drop the answer check | :1712 |
2 failed — including a two-agent chain ends |
| whole-handle → substring | :627 |
1 failed — a longer handle that starts with the sender's |
| canonical handle only | :1685 |
1 failed — a sender that is not installed in the pod … is not named |
| window 30 → 60 min | :622 |
1 failed — reads the TARGET's messages … within 30 minutes |
| cue never rendered | :942 |
3 failed |
| an error no longer means no cue (mine) | :1718 |
1 failed — a failed check means no cue, and the mention is still delivered |
SQL mutation (pg/Message.ts:333) |
result |
|---|---|
| drop the pod bound | 2 failed |
| drop the author bound | 4 failed |
| drop the window | 2 failed |
| keep system rows | 2 failed |
ORDER BY id DESC → ASC |
2 failed |
The pg-mem suite calls the shipped method and says in its docblock why the service suite cannot — the lesson from #2363's findPreviousTopLevel carried forward, which is the right place for it.
Three things to know, none blocking
The cited backstops resolve, and the bound is looser than "bounded" sounds. MENTION_LOOP_WINDOW_MS = 5 * 60 * 1000 and MENTION_LOOP_MAX = 3 at :137-138, and CASCADE_DEFAULTS at cli/src/lib/enforcement.js:102. So a chain that keeps going is capped at three mentions per bot per pod per five minutes — real, and worth reading as the actual ceiling rather than as a stop.
The 20-row cap is where the answer check can miss. The guard reads the newest 20 of the target's messages in the window; if the target posts more than 20 in thirty minutes and its mention-back is older than those twenty, the cue fires again. The direction is over-cue, which the PR already accepts, and the row cap keeps the newest pins the cap itself — this is just the precise shape of the accepted cost.
message_type != 'system' is safe here because the column is NOT NULL DEFAULT 'text' in config/schema.sql; in SQL a NULL would be excluded by that predicate rather than kept, so the NOT NULL is what makes "system rows excluded" mean only that.
Compatibility with #2359
git merge-tree is clean, and I checked the stronger thing, since a clean textual merge cannot tell you the optional parameter was threaded correctly: the real merge of both heads typechecks with no error in either shared file, and 13 suites / 193 tests pass. The optional seventh parameter is what makes it safe — #2359's adjacency site passes six arguments and its sender is always human, so an uncued adjacency mention is correct rather than a dropped cue.
One process fault of mine, since it touched this gate
To build that merge I ran git worktree add -f /tmp/wtm67 pr2367 — a second checkout of the same branch — and the subsequent git merge advanced the shared ref, so the worktree running the mutation battery had its HEAD silently moved to my merge commit mid-run. The battery stayed valid: each worktree has its own working directory and index, and my harness restores from a copy taken at 8b0f3a09, which I confirmed with cmp on both files. But two readings I took after the merge through git show pr2367: were contaminated, and the call-site count above is the re-derivation against the explicit sha — read off the branch name it would have shown six sites including #2359's, and I would have filed a call site this PR does not have. The local ref is restored to the PR head.
Written by sprint-review, a Commonly agent — https://commonly.me/v2/pods/6a692a1be833c668acdb84cf
GATE: PASS
C14 call rehearsal, 2026-10-20: Claude handed Codex a change and promised to review it; Codex posted the result without @mentioning Claude, so Claude never woke and the review never came. The kernel now cues the first handoff (#2367); the skill states the rule for every runtime that loads it. Edited in docs/agents/skills/commonly/SKILL.md (prepublishOnly copies it over the packaged one) and mirrored; skill-sync pins it beside the #702 guardrail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
Why
C14 call rehearsal, on live, 10-20:
On the call, that is the "two vendors in one pod" moment. The workaround is for Sam to type
@<their>-claude review itby hand.What
backend/services/agentMentionService.ts: when an agent @mentions another agent, the target'schat.mention/thread.mentioncontent carries one more frame, last before the body:Ping-pong guard (Cofounder's condition). Only the first handoff in a chain is cued.
MENTION_LOOP_MAX(3) mentions of one bot in one pod within 5 minutes are dropped, on every runtime;CASCADE_DEFAULTSincli/src/lib/enforcement.js.Details:
@aria,@openclaw-aria, a display-name slug) counts as the answer. The cue names the bot username, and matching is whole-handle, so@x-botis not@x.Message.recentContentByAuthor(podId, userId, since, limit = 20): this pod, this author, created aftersince, system rows excluded, newest first.Known over-cue. An agent's first mention of another agent that isn't a handoff, like "nice work @codex" or "as @codex said", gets the cue too. The worst case is one extra "@sender" reply; the target's own next mention then ends it.
Tests
agentMentionService.handoffCue.test.js, 8 arms:Mutations, each fails a test:
recentContentByAuthor.pgmem.test.js, 7 arms. SQL mutations, each fails a test:pod_iduser_idORDER BYascendingThe same 7 arms also ran once on real Postgres 16 with
config/schema.sql, from a loopback container and an uncommitted copy of the suite: 7 of 7 pass.All 10
agentMentionService*suites pass, 163 tests (170 with the pg-mem suite), on Node 22.tscreports nothing in the touched files.Merge
git merge-treeagainst its head is clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG