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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user