Skip to content

feat(mentions): an agent handed work by another is told to @mention it back - #2367

Merged
lilyshen0722 merged 2 commits into
mainfrom
feat/agent-handoff-cue
Oct 21, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
feat/agent-handoff-cue

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Why

C14 call rehearsal, on live, 10-20:

  1. Claude handed a change to Codex and said it would review it once it was up.
  2. Codex posted the result without @mentioning Claude.
  3. An agent only wakes on an @mention, so Claude never saw the result, and the promised review never came.

On the call, that is the "two vendors in one pod" moment. The workaround is for Sam to type @<their>-claude review it by hand.

What

backend/services/agentMentionService.ts: when an agent @mentions another agent, the target's chat.mention / thread.mention content carries one more frame, last before the body:

[@laruiss-claude handed this to you. When it's done, post the result and @mention @laruiss-claude: an agent only wakes on an @mention, so without it they never see your result.]

Ping-pong guard (Cofounder's condition). 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, so there's no cue.
  • So "done, @claude" carries no cue, and neither does the "thanks @codex" after it. A two-agent chain ends once the result is back.
  • A chain that keeps going anyway still meets the two guards that already bound bot-to-bot mentions:

Details:

  • Who counts as the sender. The sender's handles are every mention-map key that resolves to it in this pod. A mention back by any of them (@aria, @openclaw-aria, a display-name slug) counts as the answer. The cue names the bot username, and matching is whole-handle, so @x-bot is not @x.
  • No cue, delivered as before. A human sender, a sender not installed in the pod (a mention can't wake it), or a failed check each get no cue, and the mention is delivered exactly as before.
  • New model read Message.recentContentByAuthor(podId, userId, since, limit = 20): this pod, this author, created after since, system rows excluded, newest first.
  • Frame budget. Unchanged: the budget's reference wake is a human's mention, which never carries this cue. The cue adds about 170 characters plus two handles, only on the first agent-to-agent handoff.

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:

  • the first handoff is cued with the exact copy;
  • the two-agent chain ends: handoff cued, result back not cued, thanks not cued;
  • a human sender gets no cue;
  • an answer by another handle counts;
  • a longer handle isn't the sender;
  • a sender not installed in the pod gets no cue;
  • a failed check gives no cue and the mention is still delivered;
  • the check reads the target's messages in this pod, over 30 minutes.

Mutations, each fails a test:

mutation result
no answer check 2 failed
no whole-handle boundary 1 failed
canonical handle only 1 failed
60-minute window 1 failed
read the sender's messages 3 failed
cue never rendered 3 failed

recentContentByAuthor.pgmem.test.js, 7 arms. SQL mutations, each fails a test:

mutation result
drop pod_id 2 failed
drop user_id 4 failed
drop the window 2 failed
keep system rows 2 failed
ORDER BY ascending 2 failed

The 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. tsc reports nothing in the touched files.

Merge

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG

lilyshen0722 and others added 2 commits October 20, 2026 15:37
…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
samxu01 pushed a commit that referenced this pull request Oct 20, 2026
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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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: no message.posted call site passes a seventh argument, so handoffFrom is undefined there whatever the guard says. Correct defensive code.
  • Removing isSelfMention(target) from handoffFrom is 170 / 170. Redundant on the direct path (:1732) and the fuzzy path (:1857), both of which return early upstream with skipped.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

samxu01 pushed a commit that referenced this pull request Oct 21, 2026
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
lilyshen0722 marked this pull request as ready for review October 21, 2026 19:03
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit ea98fc6 Oct 21, 2026
23 checks passed
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