feat: turn-based speaking + slash commands + enhanced prompts

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 - 重置轮流顺序
This commit is contained in:
zhi
2026-02-27 16:05:39 +00:00
parent 52a613fdcc
commit 1d8881577d
3 changed files with 322 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; 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"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
@@ -24,9 +25,13 @@ type DebugConfig = {
const sessionDecision = new Map<string, DecisionRecord>(); const sessionDecision = new Map<string, DecisionRecord>();
const MAX_SESSION_DECISIONS = 2000; const MAX_SESSION_DECISIONS = 2000;
const DECISION_TTL_MS = 5 * 60 * 1000; 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("") : "🔚"; 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 = { const policyState: PolicyState = {
@@ -153,12 +158,40 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf
const raw = fs.readFileSync(filePath, "utf8"); const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>; const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {}; policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
syncTurnPolicies();
} catch (err) { } catch (err) {
api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`); api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
policyState.channelPolicies = {}; policyState.channelPolicies = {};
} }
} }
/** Resolve agentId → Discord accountId from config bindings */
function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | 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 { function persistPolicies(api: OpenClawPluginApi): void {
const filePath = policyState.filePath; const filePath = policyState.filePath;
if (!filePath) throw new Error("policy file path not initialized"); 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.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(tmp, before, "utf8"); fs.writeFileSync(tmp, before, "utf8");
fs.renameSync(tmp, filePath); fs.renameSync(tmp, filePath);
syncTurnPolicies();
api.logger.info(`whispergate: policy file persisted: ${filePath}`); api.logger.info(`whispergate: policy file persisted: ${filePath}`);
} }
@@ -234,7 +268,7 @@ export default {
api.registerTool( api.registerTool(
{ {
name: "whispergateway_tools", name: "whispergate_tools",
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.", description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
parameters: { parameters: {
type: "object", type: "object",
@@ -242,7 +276,7 @@ export default {
properties: { properties: {
action: { action: {
type: "string", 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" }, guildId: { type: "string" },
name: { type: "string" }, name: { type: "string" },
@@ -269,6 +303,7 @@ export default {
humanList: { type: "array", items: { type: "string" } }, humanList: { type: "array", items: { type: "string" } },
agentList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } },
endSymbols: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } },
turnOrder: { type: "array", items: { type: "string" } },
}, },
required: ["action"], required: ["action"],
}, },
@@ -303,7 +338,7 @@ export default {
const text = await r.text(); const text = await r.text();
if (!r.ok) { if (!r.ok) {
return { 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, isError: true,
}; };
} }
@@ -331,6 +366,7 @@ export default {
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols 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<string, unknown>) as ChannelPolicy; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
persistPolicies(api); 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 }; return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
}, },
}, },
@@ -370,6 +426,16 @@ export default {
if (shouldDebugLog(livePre, preChannelId)) { if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`); 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<string, unknown>).from === "string" ? (e as Record<string, unknown>).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) { } catch (err) {
api.logger.warn(`whispergate: message hook failed: ${String(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) { if (!rec.decision.shouldUseNoReply) {
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型 // 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
if (rec.needsRestore) { if (rec.needsRestore) {
@@ -512,10 +595,104 @@ export default {
const prompt = ((event as Record<string, unknown>).prompt as string) || ""; const prompt = ((event as Record<string, unknown>).prompt as string) || "";
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); 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 }; 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)}`);
}
});
}, },
}; };

View File

@@ -17,6 +17,8 @@ export type ChannelPolicy = {
humanList?: string[]; humanList?: string[];
agentList?: string[]; agentList?: string[];
endSymbols?: string[]; endSymbols?: string[];
/** Ordered list of Discord account IDs for turn-based speaking */
turnOrder?: string[];
}; };
export type Decision = { export type Decision = {

136
plugin/turn-manager.ts Normal file
View File

@@ -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<string, ChannelTurnState>();
/** Turn policies per channel (loaded from channel policies) */
const turnPolicies = new Map<string, TurnPolicy>();
/** 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<string, unknown> {
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,
};
}