feat(group-chat): add @all mention routing (#857)

Add modular group-chat mention routing helpers for the reserved @all token, route it to every non-sender agent, and strip routing tokens before model input.

Expose @all in mention autocomplete, highlight it in group messages, reserve literal all agent names, and cover boundary/partial-match regressions with tests.
This commit is contained in:
Zhicheng Han
2026-05-20 04:21:57 +02:00
committed by GitHub
parent 210b0ee6c2
commit 904ca8c648
10 changed files with 386 additions and 42 deletions
@@ -5,6 +5,11 @@ import { updateUsage } from '../../../db/hermes/usage-store'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
import type { ContentBlock } from '../run-chat/types'
import {
isAllAgentsMentioned,
resolveMentionTargets,
stripMentionRoutingTokens,
} from './mention-routing'
// ─── Types ────────────────────────────────────────────────────
@@ -302,20 +307,20 @@ class AgentClient {
}
}
// Keep the original mentions visible and add an explicit routing note.
// When a user mentions multiple agents, stripping only this agent's
// name can make the remaining input look like it was meant for
// someone else.
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
// Keep routing explicit while removing only the mention tokens that
// selected this agent. This avoids making @all look like an
// instruction for the model to fan out another routing cycle.
const routedPrefix = isAllAgentsMentioned(msg.content)
? `群聊系统:这条消息通过 @all 提及所有 agent,你是其中之一,请直接回复。`
: `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
const rawInput = msg.input || msg.content
const input = isContentBlockArray(rawInput)
? rawInput.map((block) => {
if (block.type !== 'text') return block
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
const text = stripMentionRoutingTokens(String(block.text || msg.content), this.name)
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
})
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
: `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}`
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
? await convertContentBlocksForAgent(input)
: input
@@ -815,12 +820,7 @@ export class AgentClients {
*/
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
const agents = this.getAgents(roomId)
const senderName = msg.senderName.toLowerCase()
const mentioned = agents.filter(a => (
a.name.toLowerCase() !== senderName &&
isAgentMentioned(msg.content, a.name)
))
const mentioned = resolveMentionTargets(agents, msg.content, msg.senderId)
if (mentioned.length === 0) return
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
@@ -886,9 +886,3 @@ export class AgentClients {
function nextMentionDepth(msg: MentionMessage): number {
return Math.max(0, msg.mentionDepth || 0) + 1
}
function isAgentMentioned(content: string, agentName: string): boolean {
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
return pattern.test(content)
}
@@ -0,0 +1,103 @@
export const ALL_AGENTS_MENTION = 'all'
type MentionableAgent = {
name: string
id?: string
agentId?: string
}
type MentionRange = {
start: number
end: number
}
const BEFORE_BOUNDARY = new Set(['(', '[', '{', '<'])
const AFTER_BOUNDARY = new Set(['.', ',', '!', '?', ';', ':', '', '。', '', '', '', '', ')', ']', '}', '>'])
export function escapeMentionName(name: string): string {
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function isReservedMentionName(name: string): boolean {
return name.trim().toLowerCase() === ALL_AGENTS_MENTION
}
function isBeforeBoundary(char: string | undefined): boolean {
return char === undefined || /\s/.test(char) || BEFORE_BOUNDARY.has(char)
}
function isAfterBoundary(char: string | undefined): boolean {
return char === undefined || /\s/.test(char) || AFTER_BOUNDARY.has(char)
}
function findMentionRanges(content: string, mentionName: string): MentionRange[] {
if (!content || !mentionName) return []
const contentLower = content.toLowerCase()
const mentionLower = mentionName.toLowerCase()
const ranges: MentionRange[] = []
let fromIndex = 0
while (fromIndex < content.length) {
const atIndex = contentLower.indexOf(`@${mentionLower}`, fromIndex)
if (atIndex === -1) break
const start = atIndex
const end = atIndex + mentionName.length + 1
if (isBeforeBoundary(content[start - 1]) && isAfterBoundary(content[end])) {
ranges.push({ start, end })
}
fromIndex = atIndex + 1
}
return ranges
}
export function isAgentMentioned(content: string, agentName: string): boolean {
return findMentionRanges(content, agentName).length > 0
}
export function isAllAgentsMentioned(content: string): boolean {
return isAgentMentioned(content, ALL_AGENTS_MENTION)
}
function isSenderAgent(agent: MentionableAgent, senderId: string): boolean {
return Boolean(senderId && (agent.id === senderId || agent.agentId === senderId))
}
export function resolveMentionTargets<T extends MentionableAgent>(
agents: T[],
content: string,
senderId: string,
): T[] {
const candidates = agents.filter((agent) => !isSenderAgent(agent, senderId))
if (isAllAgentsMentioned(content)) {
return candidates
}
return candidates.filter((agent) => isAgentMentioned(content, agent.name))
}
export function stripMentionRoutingTokens(content: string, ownAgentName: string): string {
const rangesByKey = new Map<string, MentionRange>()
for (const range of [
...findMentionRanges(content, ALL_AGENTS_MENTION),
...findMentionRanges(content, ownAgentName),
]) {
rangesByKey.set(`${range.start}:${range.end}`, range)
}
const ranges = [...rangesByKey.values()].sort((a, b) => b.start - a.start)
let result = content
for (const range of ranges) {
result = `${result.slice(0, range.start)}${result.slice(range.end)}`
}
return result
.replace(/^[\s,:;.!?。!?]+/, '')
.replace(/[\s,:;]+$/g, '')
.replace(/[ \t]{2,}/g, ' ')
.trim()
}