From 80439b0912b8136eb930d754cd055f89f414a2e4 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:34:37 +0000 Subject: [PATCH 1/4] Revert "Merge pull request 'fix: use systemPrompt instead of prependContext for end marker instruction' (#5) from fix/moderator-and-system-prompt into feat/turn-based-speaking" This reverts commit 6a81f75fd021140a222c665ac0ed4b2610c69deb, reversing changes made to 86fdc63802cb8df0213ef31f141601a031ef925a. --- docs/CONFIG.example.json | 3 +-- plugin/index.ts | 55 +++------------------------------------- 2 files changed, 5 insertions(+), 53 deletions(-) diff --git a/docs/CONFIG.example.json b/docs/CONFIG.example.json index a18ed96..d14f120 100644 --- a/docs/CONFIG.example.json +++ b/docs/CONFIG.example.json @@ -22,8 +22,7 @@ "debugLogChannelIds": [], "discordControlApiBaseUrl": "http://127.0.0.1:8790", "discordControlApiToken": "", - "discordControlCallerId": "agent-main", - "moderatorBotToken": "" + "discordControlCallerId": "agent-main" } } } diff --git a/plugin/index.ts b/plugin/index.ts index 8bbb6fb..2dd9411 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,7 +24,6 @@ type DebugConfig = { }; const sessionDecision = new Map(); -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 { @@ -262,20 +261,8 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; const acct = accounts[accountId]; - - if (!acct?.token || typeof acct.token !== "string") { - api.logger.warn(`whispergate: resolveDiscordUserId failed for accountId=${accountId}: no token found in config`); - return undefined; - } - - const userId = userIdFromToken(acct.token); - if (!userId) { - api.logger.warn(`whispergate: resolveDiscordUserId failed for accountId=${accountId}: could not parse userId from token`); - return undefined; - } - - api.logger.info(`whispergate: resolveDiscordUserId success accountId=${accountId} userId=${userId}`); - return userId; + if (!acct?.token || typeof acct.token !== "string") return undefined; + return userIdFromToken(acct.token); } /** Get the moderator bot's Discord user ID from its token */ @@ -624,27 +611,7 @@ export default { // This ensures only the current speaker can respond even for human messages. if (derived.channelId) { ensureTurnOrder(api, derived.channelId); - - // Try resolveAccountId first, fall back to ctx.accountId if not found - let accountId = resolveAccountId(api, ctx.agentId || ""); - - // Debug log for turn check - if (shouldDebugLog(live, derived.channelId)) { - const turnDebug = getTurnDebugInfo(derived.channelId); - api.logger.info( - `whispergate: turn check preflight agentId=${ctx.agentId ?? "undefined"} ` + - `resolvedAccountId=${accountId ?? "undefined"} ` + - `ctxAccountId=${ctx.accountId ?? "undefined"} ` + - `turnOrderLen=${turnDebug.turnOrder?.length ?? 0} ` + - `currentSpeaker=${turnDebug.currentSpeaker ?? "null"}`, - ); - } - - // Fallback to ctx.accountId if resolveAccountId failed - if (!accountId && ctx.accountId) { - accountId = String(ctx.accountId); - } - + const accountId = resolveAccountId(api, ctx.agentId || ""); if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); if (!turnCheck.allowed) { @@ -736,17 +703,6 @@ 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( @@ -770,10 +726,7 @@ export default { if (idStr) identity = idStr + "\n\n"; } - // Mark session as injected (one-time injection) - sessionInjected.add(key); - - api.logger.info(`whispergate: one-time inject end marker 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 }; }); -- 2.49.1 From 1246e476dcae8c870a77d3d885b60f6595e7f91d Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:37:11 +0000 Subject: [PATCH 2/4] fix: bypass no-reply for moderator bot handoff messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add moderator bypass in before_model_resolve: if senderId equals moderatorUserId, skip no-reply evaluation to prevent moderator handoff messages from being treated as regular messages without 🔚 - Add debug log when bypass is triggered --- plugin/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugin/index.ts b/plugin/index.ts index 2dd9411..0abe30c 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -581,6 +581,18 @@ export default { const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):"); if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return; + // Moderator bypass: if sender is the moderator bot, don't trigger no-reply + // This prevents moderator handoff messages from being treated as regular messages without 🔚 + const moderatorUserId = getModeratorUserId(live); + if (moderatorUserId && derived.senderId === moderatorUserId) { + if (shouldDebugLog(live, derived.channelId)) { + api.logger.info( + `whispergate: moderator bypass for senderId=${derived.senderId}, skipping no-reply`, + ); + } + return; + } + let rec = sessionDecision.get(key); if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { if (rec) sessionDecision.delete(key); -- 2.49.1 From db3df0688bcafc1f850e67fa5658e7e220bba175 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:41:59 +0000 Subject: [PATCH 3/4] fix: add turn check debug logs + fallback to ctx.accountId - Add detailed debug log for turn check showing agentId, resolvedAccountId, ctxAccountId, turnOrderLen - Add fallback: if resolveAccountId fails, use ctx.accountId directly --- plugin/index.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/plugin/index.ts b/plugin/index.ts index 0abe30c..377b4b3 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -623,7 +623,28 @@ export default { // This ensures only the current speaker can respond even for human messages. if (derived.channelId) { ensureTurnOrder(api, derived.channelId); - const accountId = resolveAccountId(api, ctx.agentId || ""); + + // Try resolveAccountId first, fall back to ctx.accountId if not found + let accountId = resolveAccountId(api, ctx.agentId || ""); + + // Debug log for turn check - log all available identifiers + if (shouldDebugLog(live, derived.channelId)) { + const turnDebug = getTurnDebugInfo(derived.channelId); + api.logger.info( + `whispergate: turn check preflight ` + + `agentId=${ctx.agentId ?? "undefined"} ` + + `resolvedAccountId=${accountId ?? "undefined"} ` + + `ctxAccountId=${ctx.accountId ?? "undefined"} ` + + `turnOrderLen=${turnDebug.turnOrder?.length ?? 0} ` + + `currentSpeaker=${turnDebug.currentSpeaker ?? "null"}`, + ); + } + + // Fallback to ctx.accountId if resolveAccountId failed + if (!accountId && ctx.accountId) { + accountId = String(ctx.accountId); + } + if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); if (!turnCheck.allowed) { -- 2.49.1 From 532d829ca54365a998ffed3adb36120111b1628c Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 28 Feb 2026 19:49:09 +0000 Subject: [PATCH 4/4] fix: prioritize ctx.channelId over prompt for turn gate - Add channelIdFromCtx parameter to deriveDecisionInputFromPrompt - Priority: ctx.channelId > conv.chat_id > conv.channel_id - This fixes turn gate not working because channelId was empty/wrong - Update all 3 call sites to pass ctx.channelId --- plugin/index.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 377b4b3..2e0fb47 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -82,6 +82,7 @@ function extractUntrustedConversationInfo(text: string): Record function deriveDecisionInputFromPrompt( prompt: string, messageProvider?: string, + channelIdFromCtx?: string, ): { channel: string; channelId?: string; @@ -91,11 +92,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 +583,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; @@ -712,7 +719,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, @@ -747,7 +754,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); -- 2.49.1