import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { resolvePolicy, type DirigentConfig } from "../rules.js"; import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js"; type DebugConfig = { enableDebugLogs?: boolean; debugLogChannelIds?: string[]; }; type BeforeMessageWriteDeps = { api: OpenClawPluginApi; baseConfig: DirigentConfig; policyState: { channelPolicies: Record }; sessionAllowed: Map; sessionChannelId: Map; sessionAccountId: Map; sessionTurnHandled: Set; ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined; isMultiMessageMode: (channelId: string) => boolean; sendModeratorMessage: ( botToken: string, channelId: string, content: string, logger: { info: (m: string) => void; warn: (m: string) => void }, ) => Promise; discussionService?: { maybeSendIdleReminder: (channelId: string) => Promise; getDiscussion: (channelId: string) => { status: string } | undefined; }; }; export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void { const { api, baseConfig, policyState, sessionAllowed, sessionChannelId, sessionAccountId, sessionTurnHandled, ensurePolicyStateLoaded, shouldDebugLog, ensureTurnOrder, resolveDiscordUserId, isMultiMessageMode, sendModeratorMessage, discussionService, } = deps; api.on("before_message_write", (event, ctx) => { try { api.logger.info( `dirigent: DEBUG before_message_write eventKeys=${JSON.stringify(Object.keys(event ?? {}))} ctxKeys=${JSON.stringify(Object.keys(ctx ?? {}))}`, ); const key = ctx.sessionKey; let channelId: string | undefined; let accountId: string | undefined; if (key) { channelId = sessionChannelId.get(key); accountId = sessionAccountId.get(key); } let content = ""; const msg = (event as Record).message as Record | undefined; const msgContent = msg?.content; if (msg) { const role = msg.role as string | undefined; if (role && role !== "assistant") return; // Detect tool calls — intermediate model step, not a final response. // Skip turn processing entirely to avoid false NO_REPLY detection. if (Array.isArray(msgContent)) { const hasToolCalls = (msgContent as Record[]).some( (part) => part?.type === "toolCall" || part?.type === "tool_call" || part?.type === "tool_use", ); if (hasToolCalls) { api.logger.info( `dirigent: before_message_write skipping tool-call message session=${key ?? "undefined"} channel=${channelId ?? "undefined"}`, ); return; } } if (typeof msg.content === "string") { content = msg.content; } else if (Array.isArray(msg.content)) { for (const part of msg.content) { if (typeof part === "string") content += part; else if (part && typeof part === "object" && typeof (part as Record).text === "string") { content += (part as Record).text; } } } } if (!content) { content = ((event as Record).content as string) || ""; } api.logger.info( `dirigent: DEBUG before_message_write session=${key ?? "undefined"} channel=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} contentType=${typeof content} content=${String(content).slice(0, 200)}`, ); if (!key || !channelId || !accountId) return; const currentTurn = getTurnDebugInfo(channelId); if (currentTurn.currentSpeaker !== accountId) { api.logger.info( `dirigent: before_message_write skipping non-current-speaker session=${key} accountId=${accountId} currentSpeaker=${currentTurn.currentSpeaker}`, ); return; } const live = baseConfig as DirigentConfig & DebugConfig; ensurePolicyStateLoaded(api, live); const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); const trimmed = content.trim(); 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/NO_REPLY keywords as no-reply. const wasNoReply = isNoReply; const turnDebug = getTurnDebugInfo(channelId); api.logger.info( `dirigent: DEBUG turn state channel=${channelId} turnOrder=${JSON.stringify(turnDebug.turnOrder)} currentSpeaker=${turnDebug.currentSpeaker} noRepliedThisCycle=${JSON.stringify([...turnDebug.noRepliedThisCycle])}`, ); if (hasWaitIdentifier) { setWaitingForHuman(channelId); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( `dirigent: before_message_write wait-for-human triggered session=${key} channel=${channelId} accountId=${accountId}`, ); return; } const wasAllowed = sessionAllowed.get(key); if (wasNoReply) { 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; if (wasAllowed === false) { sessionAllowed.delete(key); api.logger.info( `dirigent: before_message_write forced no-reply session=${key} channel=${channelId} - not advancing turn`, ); return; } void ensureTurnOrder(api, channelId); const nextSpeaker = onSpeakerDone(channelId, accountId, true); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( `dirigent: before_message_write real no-reply session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, ); if (!nextSpeaker) { if (discussionService?.getDiscussion(channelId)?.status === "active") { void discussionService.maybeSendIdleReminder(channelId).catch((err) => { api.logger.warn(`dirigent: idle reminder failed: ${String(err)}`); }); } if (shouldDebugLog(live, channelId)) { api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`); } return; } if (live.moderatorBotToken) { if (isMultiMessageMode(channelId)) { // In multi-message mode, send the prompt marker instead of scheduling identifier const promptMarker = live.multiMessagePromptMarker || "⤵️"; void sendModeratorMessage(live.moderatorBotToken, channelId, promptMarker, api.logger).catch((err) => { api.logger.warn(`dirigent: before_message_write multi-message prompt marker failed: ${String(err)}`); }); } else { const nextUserId = resolveDiscordUserId(api, nextSpeaker); if (nextUserId) { const schedulingId = live.schedulingIdentifier || "➡️"; const handoffMsg = `<@${nextUserId}>${schedulingId}`; void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`); }); } else { api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); } } } } else if (hasEndSymbol) { void ensureTurnOrder(api, channelId); const nextSpeaker = onSpeakerDone(channelId, accountId, false); sessionAllowed.delete(key); sessionTurnHandled.add(key); api.logger.info( `dirigent: before_message_write end-symbol turn advance session=${key} channel=${channelId} nextSpeaker=${nextSpeaker ?? "dormant"}`, ); } else { api.logger.info(`dirigent: before_message_write no turn action needed session=${key} channel=${channelId}`); return; } } catch (err) { api.logger.warn(`dirigent: before_message_write hook failed: ${String(err)}`); } }); }