diff --git a/plugin/index.ts b/plugin/index.ts index 2dd9411..1de1e3a 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,6 +24,8 @@ type DebugConfig = { }; const sessionDecision = new Map(); +const sessionAllowed = new Map(); // Track if session was allowed to speak (true) or forced no-reply (false) +const sessionInjected = new Set(); // Track which sessions have already injected the end marker const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string { @@ -82,6 +84,7 @@ function extractUntrustedConversationInfo(text: string): Record function deriveDecisionInputFromPrompt( prompt: string, messageProvider?: string, + channelIdFromCtx?: string, ): { channel: string; channelId?: string; @@ -91,11 +94,17 @@ function deriveDecisionInputFromPrompt( } { const conv = extractUntrustedConversationInfo(prompt) || {}; const channel = (messageProvider || "").toLowerCase(); - const channelId = - (typeof conv.channel_id === "string" && conv.channel_id) || - (typeof conv.chat_id === "string" && conv.chat_id.startsWith("channel:") - ? conv.chat_id.slice("channel:".length) - : undefined); + + // Priority: ctx.channelId > conv.chat_id > conv.channel_id + let channelId = channelIdFromCtx; + if (!channelId) { + 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 = (typeof conv.sender_id === "string" && conv.sender_id) || (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 const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; @@ -615,6 +624,8 @@ export default { if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); if (!turnCheck.allowed) { + // Forced no-reply - record this session as not allowed to speak + sessionAllowed.set(key, false); api.logger.info( `whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`, ); @@ -623,6 +634,8 @@ export default { 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); const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider, ctx.channelId); const decision = evaluateDecision({ config: live, @@ -703,6 +716,17 @@ export default { } 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 (shouldDebugLog(live, undefined)) { api.logger.info( @@ -714,7 +738,7 @@ export default { // Resolve end symbols from config/policy for dynamic instruction const prompt = ((event as Record).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 isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat); @@ -726,6 +750,9 @@ export default { 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}`); 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 api.on("message_sent", async (event, ctx) => { try {