From e6b20e9d52d3abe53e782b2a34945dc382a6c4ad Mon Sep 17 00:00:00 2001 From: nav Date: Tue, 10 Mar 2026 13:42:50 +0000 Subject: [PATCH 1/2] feat: treat NO/empty as gateway no-reply --- plugin/hooks/before-message-write.ts | 10 ++++++---- plugin/hooks/message-sent.ts | 8 ++++++-- plugin/index.ts | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts index b2422c0..33727c6 100644 --- a/plugin/hooks/before-message-write.ts +++ b/plugin/hooks/before-message-write.ts @@ -115,14 +115,16 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); - const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); + const isNoReply = + trimmed.length === 0 || + /^NO$/i.test(trimmed) || + /^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 waitId = live.waitIdentifier || "👤"; const hasWaitIdentifier = !!lastChar && lastChar === waitId; - // Only treat explicit NO_REPLY/HEARTBEAT_OK keywords as no-reply. - // Empty content is NOT treated as no-reply — it may come from intermediate - // model responses (e.g. thinking-only blocks) that are not final answers. + // Treat explicit NO_REPLY/HEARTBEAT_OK/NO and empty responses as no-reply. const wasNoReply = isNoReply; const turnDebug = getTurnDebugInfo(channelId); diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts index 20b2f7e..9c5d594 100644 --- a/plugin/hooks/message-sent.ts +++ b/plugin/hooks/message-sent.ts @@ -74,12 +74,16 @@ export function registerMessageSentHook(deps: MessageSentDeps): void { const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); - const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed); + const isNoReply = + trimmed.length === 0 || + /^NO$/i.test(trimmed) || + /^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 waitId = live.waitIdentifier || "👤"; const hasWaitIdentifier = !!lastChar && lastChar === waitId; - // Only treat explicit NO_REPLY/HEARTBEAT_OK keywords as no-reply. + // Treat explicit NO_REPLY/HEARTBEAT_OK/NO and empty responses as no-reply. const wasNoReply = isNoReply; if (key && sessionTurnHandled.has(key)) { diff --git a/plugin/index.ts b/plugin/index.ts index 3ef862f..d02108b 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -36,7 +36,7 @@ type DebugConfig = { function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string): string { const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; - let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK) must NOT include ${symbols}.`; + let instruction = `Your response MUST end with ${symbols}. Exception: gateway keywords (e.g. NO_REPLY, HEARTBEAT_OK, NO, or an empty response) must NOT include ${symbols}.`; if (isGroupChat) { instruction += `\n\nGroup chat rules: If this message is not relevant to you, does not need your response, or you have nothing valuable to add, reply with NO_REPLY. Do not speak just for the sake of speaking.`; instruction += `\n\nWait for human reply: If you need a human to respond to your message, end with ${waitIdentifier} instead of ${symbols}. This pauses all agents until a human speaks. Use this sparingly — only when you are confident the human is actively participating in the discussion (has sent a message recently). Do NOT use it speculatively.`; From 4905f37c1a03917b941265dbbf1fc2f4634908e8 Mon Sep 17 00:00:00 2001 From: nav Date: Tue, 10 Mar 2026 13:53:32 +0000 Subject: [PATCH 2/2] fix: limit no-reply keywords and log --- plugin/hooks/before-message-write.ts | 13 ++++++------- plugin/hooks/message-sent.ts | 12 +++++------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts index 33727c6..6cc93c0 100644 --- a/plugin/hooks/before-message-write.ts +++ b/plugin/hooks/before-message-write.ts @@ -115,16 +115,12 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); - const isNoReply = - trimmed.length === 0 || - /^NO$/i.test(trimmed) || - /^NO_REPLY$/i.test(trimmed) || - /^HEARTBEAT_OK$/i.test(trimmed); + const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed); const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const waitId = live.waitIdentifier || "👤"; const hasWaitIdentifier = !!lastChar && lastChar === waitId; - // Treat explicit NO_REPLY/HEARTBEAT_OK/NO and empty responses as no-reply. + // Treat explicit NO/NO_REPLY keywords as no-reply. const wasNoReply = isNoReply; const turnDebug = getTurnDebugInfo(channelId); @@ -145,7 +141,10 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo const wasAllowed = sessionAllowed.get(key); if (wasNoReply) { - api.logger.info(`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`); + const noReplyKeyword = /^NO$/i.test(trimmed) ? "NO" : "NO_REPLY"; + api.logger.info( + `dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed} keyword=${noReplyKeyword}`, + ); if (wasAllowed === undefined) return; diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts index 9c5d594..b9703bc 100644 --- a/plugin/hooks/message-sent.ts +++ b/plugin/hooks/message-sent.ts @@ -74,16 +74,12 @@ export function registerMessageSentHook(deps: MessageSentDeps): void { const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); - const isNoReply = - trimmed.length === 0 || - /^NO$/i.test(trimmed) || - /^NO_REPLY$/i.test(trimmed) || - /^HEARTBEAT_OK$/i.test(trimmed); + const isNoReply = /^NO$/i.test(trimmed) || /^NO_REPLY$/i.test(trimmed); const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : ""; const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar); const waitId = live.waitIdentifier || "👤"; const hasWaitIdentifier = !!lastChar && lastChar === waitId; - // Treat explicit NO_REPLY/HEARTBEAT_OK/NO and empty responses as no-reply. + // Treat explicit NO/NO_REPLY keywords as no-reply. const wasNoReply = isNoReply; if (key && sessionTurnHandled.has(key)) { @@ -105,8 +101,10 @@ export function registerMessageSentHook(deps: MessageSentDeps): void { if (wasNoReply || hasEndSymbol) { const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply); const trigger = wasNoReply ? "no_reply_keyword" : "end_symbol"; + const noReplyKeyword = wasNoReply ? (/^NO$/i.test(trimmed) ? "NO" : "NO_REPLY") : ""; + const keywordNote = wasNoReply ? ` keyword=${noReplyKeyword}` : ""; api.logger.info( - `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`, + `dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}${keywordNote}`, ); if (wasNoReply && nextSpeaker && live.moderatorBotToken) {