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 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<string, DecisionRecord>();
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<string, ChannelPolicy>;
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<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 {
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<string, unknown>) 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<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) {
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<string, unknown>).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)}`);
}
});
},
};