fix: implement turn gate and handoff improvements
1. Fix channelId priority: use ctx.channelId > conv.chat_id > conv.channel_id 2. Add sessionAllowed state: track if session was allowed (true) or forced no-reply (false) 3. Add sessionInjected Set: implement one-time prependContext injection 4. Add before_message_write hook: detect NO_REPLY and handle turn advancement - forced no-reply: don't advance turn - allowed + NO_REPLY: advance turn + handoff - dormant state: don't trigger handoff 5. message_sent: continues to handle real messages with end symbols
This commit is contained in:
114
plugin/index.ts
114
plugin/index.ts
@@ -24,6 +24,8 @@ type DebugConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sessionDecision = new Map<string, DecisionRecord>();
|
const sessionDecision = new Map<string, DecisionRecord>();
|
||||||
|
const sessionAllowed = new Map<string, boolean>(); // Track if session was allowed to speak (true) or forced no-reply (false)
|
||||||
|
const sessionInjected = new Set<string>(); // Track which sessions have already injected the end marker
|
||||||
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[], isGroupChat: boolean): string {
|
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string {
|
||||||
@@ -82,6 +84,7 @@ function extractUntrustedConversationInfo(text: string): Record<string, unknown>
|
|||||||
function deriveDecisionInputFromPrompt(
|
function deriveDecisionInputFromPrompt(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
messageProvider?: string,
|
messageProvider?: string,
|
||||||
|
channelIdFromCtx?: string,
|
||||||
): {
|
): {
|
||||||
channel: string;
|
channel: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
@@ -91,11 +94,17 @@ function deriveDecisionInputFromPrompt(
|
|||||||
} {
|
} {
|
||||||
const conv = extractUntrustedConversationInfo(prompt) || {};
|
const conv = extractUntrustedConversationInfo(prompt) || {};
|
||||||
const channel = (messageProvider || "").toLowerCase();
|
const channel = (messageProvider || "").toLowerCase();
|
||||||
const channelId =
|
|
||||||
(typeof conv.channel_id === "string" && conv.channel_id) ||
|
// Priority: ctx.channelId > conv.chat_id > conv.channel_id
|
||||||
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
|
let channelId = channelIdFromCtx;
|
||||||
? conv.chat_id.slice("channel:".length)
|
if (!channelId) {
|
||||||
: undefined);
|
channelId =
|
||||||
|
(typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:")
|
||||||
|
? conv.chat_id.slice("channel:".length)
|
||||||
|
: typeof conv.channel_id === "string" && conv.channel_id) ||
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const senderId =
|
const senderId =
|
||||||
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
(typeof conv.sender_id === "string" && conv.sender_id) ||
|
||||||
(typeof conv.sender === "string" && conv.sender) ||
|
(typeof conv.sender === "string" && conv.sender) ||
|
||||||
@@ -576,7 +585,7 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId);
|
||||||
// Only proceed if: discord channel AND prompt contains untrusted metadata
|
// Only proceed if: discord channel AND prompt contains untrusted metadata
|
||||||
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
|
||||||
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
|
||||||
@@ -615,6 +624,8 @@ export default {
|
|||||||
if (accountId) {
|
if (accountId) {
|
||||||
const turnCheck = checkTurn(derived.channelId, accountId);
|
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||||
if (!turnCheck.allowed) {
|
if (!turnCheck.allowed) {
|
||||||
|
// Forced no-reply - record this session as not allowed to speak
|
||||||
|
sessionAllowed.set(key, false);
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||||
);
|
);
|
||||||
@@ -623,6 +634,8 @@ export default {
|
|||||||
modelOverride: live.noReplyModel,
|
modelOverride: live.noReplyModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Allowed to speak - record this session as allowed
|
||||||
|
sessionAllowed.set(key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +692,7 @@ export default {
|
|||||||
if (rec) sessionDecision.delete(key);
|
if (rec) sessionDecision.delete(key);
|
||||||
|
|
||||||
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, ctx.channelId);
|
||||||
|
|
||||||
const decision = evaluateDecision({
|
const decision = evaluateDecision({
|
||||||
config: live,
|
config: live,
|
||||||
@@ -703,6 +716,17 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionDecision.delete(key);
|
sessionDecision.delete(key);
|
||||||
|
|
||||||
|
// Only inject once per session (one-time injection)
|
||||||
|
if (sessionInjected.has(key)) {
|
||||||
|
if (shouldDebugLog(live, undefined)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: debug before_prompt_build session=${key} inject skipped (already injected)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
if (!rec.decision.shouldInjectEndMarkerPrompt) {
|
||||||
if (shouldDebugLog(live, undefined)) {
|
if (shouldDebugLog(live, undefined)) {
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
@@ -714,7 +738,7 @@ export default {
|
|||||||
|
|
||||||
// Resolve end symbols from config/policy for dynamic instruction
|
// Resolve end symbols from config/policy for dynamic instruction
|
||||||
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, ctx.channelId);
|
||||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||||
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||||
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
|
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
|
||||||
@@ -726,6 +750,9 @@ export default {
|
|||||||
if (idStr) identity = idStr + "\n\n";
|
if (idStr) identity = idStr + "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark session as injected (one-time injection)
|
||||||
|
sessionInjected.add(key);
|
||||||
|
|
||||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||||
return { prependContext: identity + instruction };
|
return { prependContext: identity + instruction };
|
||||||
});
|
});
|
||||||
@@ -776,6 +803,77 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle NO_REPLY detection before message write
|
||||||
|
// This is where we detect if agent output is NO_REPLY and handle turn advancement
|
||||||
|
api.on("before_message_write", async (event, ctx) => {
|
||||||
|
try {
|
||||||
|
const key = ctx.sessionKey;
|
||||||
|
const channelId = ctx.channelId as string | undefined;
|
||||||
|
const accountId = ctx.accountId as string | undefined;
|
||||||
|
|
||||||
|
if (!key || !channelId || !accountId) return;
|
||||||
|
|
||||||
|
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
|
|
||||||
|
// Get the agent's output content
|
||||||
|
const content = (event.content as string) || "";
|
||||||
|
const trimmed = content.trim();
|
||||||
|
const isNoReply = /^NO_REPLY$/i.test(trimmed);
|
||||||
|
|
||||||
|
if (!isNoReply) return;
|
||||||
|
|
||||||
|
// Check if this session was forced no-reply or allowed to speak
|
||||||
|
const wasAllowed = sessionAllowed.get(key);
|
||||||
|
if (wasAllowed === undefined) return; // No record, skip
|
||||||
|
|
||||||
|
if (wasAllowed === false) {
|
||||||
|
// Forced no-reply - do not advance turn
|
||||||
|
sessionAllowed.delete(key);
|
||||||
|
if (shouldDebugLog(live, channelId)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed to speak (current speaker) but chose NO_REPLY - advance turn
|
||||||
|
ensureTurnOrder(api, channelId);
|
||||||
|
const nextSpeaker = onSpeakerDone(channelId, accountId, true);
|
||||||
|
|
||||||
|
sessionAllowed.delete(key);
|
||||||
|
|
||||||
|
if (shouldDebugLog(live, channelId)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all agents NO_REPLY'd (dormant), don't trigger handoff
|
||||||
|
if (!nextSpeaker) {
|
||||||
|
if (shouldDebugLog(live, channelId)) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: before_message_write all agents no-reply, going dormant - no handoff`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger moderator handoff message
|
||||||
|
if (live.moderatorBotToken) {
|
||||||
|
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
||||||
|
if (nextUserId) {
|
||||||
|
const handoffMsg = `轮到(<@${nextUserId}>)了,如果没有想说的请直接回复NO_REPLY`;
|
||||||
|
sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
||||||
|
} else {
|
||||||
|
api.logger.warn(`whispergate: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(`whispergate: before_message_write hook failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Turn advance: when an agent sends a message, check if it signals end of turn
|
// Turn advance: when an agent sends a message, check if it signals end of turn
|
||||||
api.on("message_sent", async (event, ctx) => {
|
api.on("message_sent", async (event, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user