From 9b5316738b9c800acc480fdd367cceff9aa106d6 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 16:05:39 +0000 Subject: [PATCH] feat: turn-based speaking + slash commands + enhanced prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rename tool: whispergateway_tools → whispergate_tools 2. Turn-based speaking mechanism: - New turn-manager.ts maintains per-channel turn state - ChannelPolicy新增turnOrder字段配置发言顺序 - before_model_resolve hook检查当前agent是否为发言人 - 非当前发言人直接切换到no-reply模型 - message_sent hook检测结束符或NO_REPLY时推进turn - message_received检测到human消息时重置turn 3. 注入提示词增强: - buildEndMarkerInstruction增加isGroupChat参数 - 群聊时追加规则:与自己无关时主动回复NO_REPLY 4. Slash command支持: - /whispergate status - 查看频道策略 - /whispergate turn-status - 查看轮流状态 - /whispergate turn-advance - 手动推进轮流 - /whispergate turn-reset - 重置轮流顺序 --- plugin/index.ts | 191 +++++++++++++++++++++++++++++++++++++++-- plugin/rules.ts | 2 + plugin/turn-manager.ts | 136 +++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 plugin/turn-manager.ts diff --git a/plugin/index.ts b/plugin/index.ts index 20ac75e..53e0adb 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; +import { checkTurn, advanceTurn, resetTurn, setTurnPolicy, getTurnDebugInfo, type TurnPolicy } from "./turn-manager.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -24,9 +25,13 @@ type DebugConfig = { const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; -function buildEndMarkerInstruction(endSymbols: string[]): string { +function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; - return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; + let instruction = `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; + if (isGroupChat) { + instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`; + } + return instruction; } const policyState: PolicyState = { @@ -153,12 +158,40 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as Record; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; + syncTurnPolicies(); } catch (err) { api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); policyState.channelPolicies = {}; } } +/** Resolve agentId → Discord accountId from config bindings */ +function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined { + const root = (api.config as Record) || {}; + const bindings = root.bindings as Array> | undefined; + if (!Array.isArray(bindings)) return undefined; + for (const b of bindings) { + if (b.agentId === agentId) { + const match = b.match as Record | undefined; + if (match?.channel === "discord" && typeof match.accountId === "string") { + return match.accountId; + } + } + } + return undefined; +} + +/** Sync turn policies from channel policies into the turn manager */ +function syncTurnPolicies(): void { + for (const [channelId, policy] of Object.entries(policyState.channelPolicies)) { + if (policy.turnOrder?.length) { + setTurnPolicy(channelId, { turnOrder: policy.turnOrder }); + } else { + setTurnPolicy(channelId, undefined); + } + } +} + function persistPolicies(api: OpenClawPluginApi): void { const filePath = policyState.filePath; if (!filePath) throw new Error("policy file path not initialized"); @@ -167,6 +200,7 @@ function persistPolicies(api: OpenClawPluginApi): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(tmp, before, "utf8"); fs.renameSync(tmp, filePath); + syncTurnPolicies(); api.logger.info(`whispergate: policy file persisted: ${filePath}`); } @@ -234,7 +268,7 @@ export default { api.registerTool( { - name: "whispergateway_tools", + name: "whispergate_tools", description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.", parameters: { type: "object", @@ -242,7 +276,7 @@ export default { properties: { action: { type: "string", - enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"], + enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"], }, guildId: { type: "string" }, name: { type: "string" }, @@ -269,6 +303,7 @@ export default { humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, + turnOrder: { type: "array", items: { type: "string" } }, }, required: ["action"], }, @@ -303,7 +338,7 @@ export default { const text = await r.text(); if (!r.ok) { return { - content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }], + content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }], isError: true, }; } @@ -331,6 +366,7 @@ export default { humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, + turnOrder: Array.isArray(params.turnOrder) ? (params.turnOrder as string[]) : undefined, }; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; persistPolicies(api); @@ -355,6 +391,26 @@ export default { } } + if (action === "turn-status") { + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] }; + } + + if (action === "turn-advance") { + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + const next = advanceTurn(channelId); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] }; + } + + if (action === "turn-reset") { + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + resetTurn(channelId); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] }; + } + return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, @@ -370,6 +426,16 @@ export default { if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); } + + // Reset turn when human sends a message in a turn-managed channel + if (preChannelId) { + const from = typeof (e as Record).from === "string" ? (e as Record).from as string : ""; + const humanList = livePre.humanList || livePre.bypassUserIds || []; + if (humanList.includes(from)) { + resetTurn(preChannelId); + api.logger.info(`whispergate: turn reset by human message in channel=${preChannelId} from=${from}`); + } + } } catch (err) { api.logger.warn(`whispergate: message hook failed: ${String(err)}`); } @@ -422,6 +488,23 @@ export default { } } + // Turn-based check: if channel has turn order, only current speaker can respond + if (!rec.decision.shouldUseNoReply && derived.channelId) { + const accountId = resolveAccountId(api, ctx.agentId || ""); + if (accountId) { + const turnCheck = checkTurn(derived.channelId, accountId); + if (!turnCheck.isSpeaker) { + api.logger.info( + `whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, + ); + return { + providerOverride: live.noReplyProvider, + modelOverride: live.noReplyModel, + }; + } + } + } + if (!rec.decision.shouldUseNoReply) { // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 if (rec.needsRestore) { @@ -512,10 +595,104 @@ export default { const prompt = ((event as Record).prompt as string) || ""; const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); - const instruction = buildEndMarkerInstruction(policy.endSymbols); + const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; + const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat); - api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); + api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); return { prependContext: instruction }; }); + + // Register slash commands for Discord + api.registerCommand({ + name: "whispergate", + description: "WhisperGate 频道策略管理", + acceptsArgs: true, + handler: async (cmdCtx) => { + const args = cmdCtx.args || ""; + const parts = args.trim().split(/\s+/); + const subCmd = parts[0] || "help"; + + if (subCmd === "help") { + return { text: `WhisperGate 命令:\n` + + `/whispergate status - 显示当前频道状态\n` + + `/whispergate turn-status - 显示轮流发言状态\n` + + `/whispergate turn-advance - 手动推进轮流\n` + + `/whispergate turn-reset - 重置轮流顺序` }; + } + + if (subCmd === "status") { + return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) }; + } + + if (subCmd === "turn-status") { + const channelId = cmdCtx.channelId; + if (!channelId) return { text: "无法获取频道ID", isError: true }; + return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }; + } + + if (subCmd === "turn-advance") { + const channelId = cmdCtx.channelId; + if (!channelId) return { text: "无法获取频道ID", isError: true }; + const next = advanceTurn(channelId); + return { text: JSON.stringify({ ok: true, nextSpeaker: next }) }; + } + + if (subCmd === "turn-reset") { + const channelId = cmdCtx.channelId; + if (!channelId) return { text: "无法获取频道ID", isError: true }; + resetTurn(channelId); + return { text: JSON.stringify({ ok: true }) }; + } + + return { text: `未知子命令: ${subCmd}`, isError: true }; + }, + }); + + // Turn advance: when an agent sends a message, check if it signals end of turn + api.on("message_sent", async (event, ctx) => { + try { + const channelId = ctx.channelId; + const accountId = ctx.accountId; + const content = event.content || ""; + + if (!channelId || !accountId) return; + + const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies); + + // Check if this message ends the turn: + // 1. Content is empty (no-reply was used) + // 2. Content ends with an end symbol + // 3. Content is a gateway keyword like NO_REPLY + const trimmed = content.trim(); + const isEmpty = trimmed.length === 0; + const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); + const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; + const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); + + if (isEmpty || isNoReply || hasEndSymbol) { + const nextSpeaker = advanceTurn(channelId); + if (nextSpeaker) { + api.logger.info( + `whispergate: turn advanced in channel=${channelId} from=${accountId} to=${nextSpeaker} ` + + `trigger=${isEmpty ? "empty" : isNoReply ? "no_reply" : "end_symbol"}`, + ); + + // Wake the next speaker by sending a nudge message to the channel + // The next agent will pick it up as a new message_received + // We use a zero-width space message to avoid visible noise + // Actually, we need the next agent's session to receive a trigger. + // The simplest approach: the turn manager just advances state. + // The next message in the channel (from any source) will allow + // the next speaker to respond. If the current speaker said NO_REPLY + // (empty content), the original message is still pending for the + // next speaker. + } + } + } catch (err) { + api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`); + } + }); }, }; diff --git a/plugin/rules.ts b/plugin/rules.ts index ca00593..4152a80 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -17,6 +17,8 @@ export type ChannelPolicy = { humanList?: string[]; agentList?: string[]; endSymbols?: string[]; + /** Ordered list of Discord account IDs for turn-based speaking */ + turnOrder?: string[]; }; export type Decision = { diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts new file mode 100644 index 0000000..7bb8403 --- /dev/null +++ b/plugin/turn-manager.ts @@ -0,0 +1,136 @@ +/** + * Turn-based speaking manager for group channels. + * + * Maintains per-channel turn order so that only one agent speaks at a time. + * When the current speaker finishes (end symbol or NO_REPLY), the turn advances + * to the next agent in the rotation. + */ + +export type TurnPolicy = { + /** Ordered list of Discord account IDs (bot user IDs) that participate in turn rotation */ + turnOrder: string[]; +}; + +export type ChannelTurnState = { + /** Index into turnOrder for the current speaker */ + currentIndex: number; + /** Timestamp of last turn advance */ + lastAdvancedAt: number; +}; + +/** In-memory turn state per channel */ +const channelTurns = new Map(); + +/** Turn policies per channel (loaded from channel policies) */ +const turnPolicies = new Map(); + +/** Turn timeout: if the current speaker hasn't responded in this time, auto-advance */ +const TURN_TIMEOUT_MS = 30_000; + +export function setTurnPolicy(channelId: string, policy: TurnPolicy | undefined): void { + if (!policy || !policy.turnOrder?.length) { + turnPolicies.delete(channelId); + channelTurns.delete(channelId); + return; + } + turnPolicies.set(channelId, policy); + // Initialize turn state if not exists + if (!channelTurns.has(channelId)) { + channelTurns.set(channelId, { currentIndex: 0, lastAdvancedAt: Date.now() }); + } +} + +export function getTurnPolicy(channelId: string): TurnPolicy | undefined { + return turnPolicies.get(channelId); +} + +export function getTurnState(channelId: string): ChannelTurnState | undefined { + return channelTurns.get(channelId); +} + +/** + * Check if the given accountId is the current speaker for this channel. + * Returns: { isSpeaker: true } or { isSpeaker: false, reason: string } + */ +export function checkTurn(channelId: string, accountId: string): { isSpeaker: boolean; currentSpeaker?: string; reason: string } { + const policy = turnPolicies.get(channelId); + if (!policy) { + return { isSpeaker: true, reason: "no_turn_policy" }; + } + + const order = policy.turnOrder; + if (!order.includes(accountId)) { + // Not in turn order — could be human or unmanaged agent, allow through + return { isSpeaker: true, reason: "not_in_turn_order" }; + } + + let state = channelTurns.get(channelId); + if (!state) { + state = { currentIndex: 0, lastAdvancedAt: Date.now() }; + channelTurns.set(channelId, state); + } + + // Auto-advance if turn has timed out + const now = Date.now(); + if (now - state.lastAdvancedAt > TURN_TIMEOUT_MS) { + advanceTurn(channelId); + state = channelTurns.get(channelId)!; + } + + const currentSpeaker = order[state.currentIndex]; + if (accountId === currentSpeaker) { + return { isSpeaker: true, currentSpeaker, reason: "is_current_speaker" }; + } + + return { isSpeaker: false, currentSpeaker, reason: "not_current_speaker" }; +} + +/** + * Advance turn to the next agent in rotation. + * Returns the new current speaker's accountId. + */ +export function advanceTurn(channelId: string): string | undefined { + const policy = turnPolicies.get(channelId); + if (!policy) return undefined; + + const order = policy.turnOrder; + let state = channelTurns.get(channelId); + if (!state) { + state = { currentIndex: 0, lastAdvancedAt: Date.now() }; + channelTurns.set(channelId, state); + return order[0]; + } + + state.currentIndex = (state.currentIndex + 1) % order.length; + state.lastAdvancedAt = Date.now(); + return order[state.currentIndex]; +} + +/** + * Reset turn to the first agent (e.g., when a human sends a message). + */ +export function resetTurn(channelId: string): void { + const state = channelTurns.get(channelId); + if (state) { + state.currentIndex = 0; + state.lastAdvancedAt = Date.now(); + } +} + +/** + * Get debug info for a channel's turn state. + */ +export function getTurnDebugInfo(channelId: string): Record { + const policy = turnPolicies.get(channelId); + const state = channelTurns.get(channelId); + if (!policy) return { channelId, hasTurnPolicy: false }; + return { + channelId, + hasTurnPolicy: true, + turnOrder: policy.turnOrder, + currentIndex: state?.currentIndex ?? 0, + currentSpeaker: policy.turnOrder[state?.currentIndex ?? 0], + lastAdvancedAt: state?.lastAdvancedAt, + timeSinceAdvanceMs: state ? Date.now() - state.lastAdvancedAt : null, + }; +}