refactor: auto-managed turn order + dormant state + identity injection

Turn system redesign:
- Turn order auto-populated from config bindings (all bot accounts)
- No manual turnOrder config needed
- Humans (humanList) excluded from turn order automatically
- Dormant state: when all agents NO_REPLY in a cycle, currentSpeaker=null
- Reactivation: any new message wakes the system
  - Human message → start from first in order
  - Bot not in order → start from first
  - Bot in order → next after sender
- Skip already-NO_REPLY'd agents when advancing

Identity injection:
- Group chat prompts now include agent identity
- Format: '你是 {name}(Discord 账号: {accountId})'

Other:
- Remove turnOrder from ChannelPolicy (no longer configurable)
- Add TURN-WAKEUP-PROBLEM.md documenting the NO_REPLY wake-up challenge
- Update message_received to call onNewMessage with proper human detection
- Update message_sent to call onSpeakerDone with NO_REPLY tracking
This commit is contained in:
zhi
2026-02-27 23:27:36 +00:00
parent 1d8881577d
commit 476308d0df
4 changed files with 377 additions and 131 deletions

View File

@@ -2,7 +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";
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
@@ -158,7 +158,6 @@ 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 = {};
@@ -181,15 +180,63 @@ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | und
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);
/**
* Get all Discord bot accountIds from config bindings (excluding humanList-bound agents).
*/
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return [];
const ids: string[] = [];
for (const b of bindings) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
ids.push(match.accountId);
}
}
return ids;
}
/**
* Ensure turn order is initialized for a channel.
* Uses all bot accounts from bindings as the turn order.
*/
function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
const botAccounts = getAllBotAccountIds(api);
if (botAccounts.length > 0) {
initTurnOrder(channelId, botAccounts);
}
}
/**
* Build agent identity string for injection into group chat prompts.
*/
function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
if (!Array.isArray(bindings)) return undefined;
// Find accountId for this agent
let accountId: string | 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") {
accountId = match.accountId;
break;
}
}
}
if (!accountId) return undefined;
// Find agent name
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
const name = (agent?.name as string) || agentId;
// Find Discord bot user ID from account token (not available directly)
// We'll use accountId as the identifier
return `你是 ${name}Discord 账号: ${accountId})。`;
}
function persistPolicies(api: OpenClawPluginApi): void {
@@ -200,7 +247,6 @@ 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}`);
}
@@ -303,7 +349,6 @@ 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"],
},
@@ -366,7 +411,6 @@ 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);
@@ -427,13 +471,17 @@ export default {
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
// Turn management on message received
if (preChannelId) {
ensureTurnOrder(api, 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}`);
const isHuman = humanList.includes(from);
// Resolve sender's accountId (for bot messages)
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
onNewMessage(preChannelId, senderAccountId, isHuman);
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
}
}
} catch (err) {
@@ -490,10 +538,11 @@ export default {
// Turn-based check: if channel has turn order, only current speaker can respond
if (!rec.decision.shouldUseNoReply && derived.channelId) {
ensureTurnOrder(api, derived.channelId);
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId);
if (!turnCheck.isSpeaker) {
if (!turnCheck.allowed) {
api.logger.info(
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
);
@@ -598,8 +647,15 @@ export default {
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
// Inject agent identity for group chats
let identity = "";
if (isGroupChat && ctx.agentId) {
const idStr = buildAgentIdentity(api, ctx.agentId);
if (idStr) identity = idStr + "\n\n";
}
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
return { prependContext: instruction };
return { prependContext: identity + instruction };
});
// Register slash commands for Discord
@@ -661,34 +717,21 @@ export default {
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);
const wasNoReply = isEmpty || isNoReply;
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.
}
if (wasNoReply || hasEndSymbol) {
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol";
api.logger.info(
`whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
);
// NOTE: if wasNoReply and nextSpeaker is set, the next agent needs
// a trigger message to start speaking. See TURN-WAKEUP-PROBLEM.md
}
} catch (err) {
api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`);