From 5c4340d5a9d3cf61eff1ac16f4c82a545af4f4ef Mon Sep 17 00:00:00 2001 From: orion Date: Sat, 7 Mar 2026 22:21:16 +0000 Subject: [PATCH] refactor(plugin): extract before_prompt_build and before_message_write hooks --- plugin/hooks/before-message-write.ts | 185 ++++++++++++++++++ plugin/hooks/before-prompt-build.ts | 134 +++++++++++++ plugin/index.ts | 271 +++------------------------ 3 files changed, 349 insertions(+), 241 deletions(-) create mode 100644 plugin/hooks/before-message-write.ts create mode 100644 plugin/hooks/before-prompt-build.ts diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts new file mode 100644 index 0000000..94e2455 --- /dev/null +++ b/plugin/hooks/before-message-write.ts @@ -0,0 +1,185 @@ +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; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; + shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => void; + resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined; + sendModeratorMessage: ( + botToken: string, + channelId: string, + content: string, + logger: { info: (m: string) => void; warn: (m: string) => void }, + ) => Promise; +}; + +export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void { + const { + api, + baseConfig, + policyState, + sessionAllowed, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + ensureTurnOrder, + resolveDiscordUserId, + sendModeratorMessage, + } = 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; + if (msg) { + const role = msg.role as string | undefined; + if (role && role !== "assistant") 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record); + + const trimmed = content.trim(); + const isEmpty = trimmed.length === 0; + const isNoReply = /^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; + const wasNoReply = isEmpty || 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) { + api.logger.info(`dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`); + + 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; + } + + 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 (shouldDebugLog(live, channelId)) { + api.logger.info(`dirigent: before_message_write all agents no-reply, going dormant - no handoff`); + } + return; + } + + if (live.moderatorBotToken) { + 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) { + 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)}`); + } + }); +} diff --git a/plugin/hooks/before-prompt-build.ts b/plugin/hooks/before-prompt-build.ts new file mode 100644 index 0000000..2c8331e --- /dev/null +++ b/plugin/hooks/before-prompt-build.ts @@ -0,0 +1,134 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { evaluateDecision, resolvePolicy, type Decision, type DirigentConfig } from "../rules.js"; +import { deriveDecisionInputFromPrompt } from "../decision-input.js"; + +type DebugConfig = { + enableDebugLogs?: boolean; + debugLogChannelIds?: string[]; +}; + +type DecisionRecord = { + decision: Decision; + createdAt: number; + needsRestore?: boolean; +}; + +type BeforePromptBuildDeps = { + api: OpenClawPluginApi; + baseConfig: DirigentConfig; + sessionDecision: Map; + sessionInjected: Set; + policyState: { channelPolicies: Record }; + DECISION_TTL_MS: number; + ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; + shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; + buildEndMarkerInstruction: (endSymbols: string[], isGroupChat: boolean, schedulingIdentifier: string, waitIdentifier: string) => string; + buildSchedulingIdentifierInstruction: (schedulingIdentifier: string) => string; + buildAgentIdentity: (api: OpenClawPluginApi, agentId: string) => string; +}; + +export function registerBeforePromptBuildHook(deps: BeforePromptBuildDeps): void { + const { + api, + baseConfig, + sessionDecision, + sessionInjected, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + buildEndMarkerInstruction, + buildSchedulingIdentifierInstruction, + buildAgentIdentity, + } = deps; + + api.on("before_prompt_build", async (event, ctx) => { + const key = ctx.sessionKey; + if (!key) return; + + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; + ensurePolicyStateLoaded(api, live); + + let rec = sessionDecision.get(key); + if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { + if (rec) sessionDecision.delete(key); + + const prompt = ((event as Record).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt({ + prompt, + messageProvider: ctx.messageProvider, + sessionKey: key, + ctx: ctx as Record, + event: event as Record, + }); + + const decision = evaluateDecision({ + config: live, + channel: derived.channel, + channelId: derived.channelId, + channelPolicies: policyState.channelPolicies as Record, + senderId: derived.senderId, + content: derived.content, + }); + rec = { decision, createdAt: Date.now() }; + if (shouldDebugLog(live, derived.channelId)) { + api.logger.info( + `dirigent: debug before_prompt_build recompute session=${key} ` + + `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + + `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + + `convSender=${String((derived.conv as Record).sender ?? "")} ` + + `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + + `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, + ); + } + } + + sessionDecision.delete(key); + + if (sessionInjected.has(key)) { + if (shouldDebugLog(live, undefined)) { + api.logger.info(`dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`); + } + return; + } + + if (!rec.decision.shouldInjectEndMarkerPrompt) { + if (shouldDebugLog(live, undefined)) { + api.logger.info(`dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`); + } + return; + } + + const prompt = ((event as Record).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt({ + prompt, + messageProvider: ctx.messageProvider, + sessionKey: key, + ctx: ctx as Record, + event: event as Record, + }); + const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies as Record); + const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; + const schedulingId = live.schedulingIdentifier || "➡️"; + const waitId = live.waitIdentifier || "👤"; + const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId); + + let identity = ""; + if (isGroupChat && ctx.agentId) { + const idStr = buildAgentIdentity(api, ctx.agentId); + if (idStr) identity = idStr + "\n\n"; + } + + let schedulingInstruction = ""; + if (isGroupChat) { + schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId); + } + + sessionInjected.add(key); + + api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); + return { prependContext: identity + instruction + schedulingInstruction }; + }); +} diff --git a/plugin/index.ts b/plugin/index.ts index 20ba5cf..81abead 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -5,9 +5,10 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride, setWaitingForHuman, isWaitingForHuman } from "./turn-manager.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; -import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "./channel-resolver.js"; -import { deriveDecisionInputFromPrompt } from "./decision-input.js"; +import { extractDiscordChannelId } from "./channel-resolver.js"; import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js"; +import { registerBeforePromptBuildHook } from "./hooks/before-prompt-build.js"; +import { registerBeforeMessageWriteHook } from "./hooks/before-message-write.js"; import { registerMessageSentHook } from "./hooks/message-sent.js"; // ── No-Reply API child process lifecycle ────────────────────────────── @@ -811,101 +812,19 @@ export default { ensureTurnOrder, }); - api.on("before_prompt_build", async (event, ctx) => { - const key = ctx.sessionKey; - if (!key) return; - - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - - let rec = sessionDecision.get(key); - if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) { - if (rec) sessionDecision.delete(key); - - const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt({ - prompt, - messageProvider: ctx.messageProvider, - sessionKey: key, - ctx: ctx as Record, - event: event as Record, - }); - - const decision = evaluateDecision({ - config: live, - channel: derived.channel, - channelId: derived.channelId, - channelPolicies: policyState.channelPolicies, - senderId: derived.senderId, - content: derived.content, - }); - rec = { decision, createdAt: Date.now() }; - if (shouldDebugLog(live, derived.channelId)) { - api.logger.info( - `dirigent: debug before_prompt_build recompute session=${key} ` + - `channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` + - `convSenderId=${String((derived.conv as Record).sender_id ?? "")} ` + - `convSender=${String((derived.conv as Record).sender ?? "")} ` + - `convChannelId=${String((derived.conv as Record).channel_id ?? "")} ` + - `decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`, - ); - } - } - - sessionDecision.delete(key); - - // Only inject once per session (one-time injection) - if (sessionInjected.has(key)) { - if (shouldDebugLog(live, undefined)) { - api.logger.info( - `dirigent: debug before_prompt_build session=${key} inject skipped (already injected)`, - ); - } - return; - } - - if (!rec.decision.shouldInjectEndMarkerPrompt) { - if (shouldDebugLog(live, undefined)) { - api.logger.info( - `dirigent: debug before_prompt_build session=${key} inject=false reason=${rec.decision.reason}`, - ); - } - return; - } - - // Resolve end symbols from config/policy for dynamic instruction - const prompt = ((event as Record).prompt as string) || ""; - const derived = deriveDecisionInputFromPrompt({ - prompt, - messageProvider: ctx.messageProvider, - sessionKey: key, - ctx: ctx as Record, - event: event as Record, - }); - const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); - const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true"; - const schedulingId = live.schedulingIdentifier || "➡️"; - const waitId = live.waitIdentifier || "👤"; - const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat, schedulingId, waitId); - - // Inject agent identity for group chats (includes userId now) - let identity = ""; - if (isGroupChat && ctx.agentId) { - const idStr = buildAgentIdentity(api, ctx.agentId); - if (idStr) identity = idStr + "\n\n"; - } - - // Add scheduling identifier instruction for group chats - let schedulingInstruction = ""; - if (isGroupChat) { - schedulingInstruction = buildSchedulingIdentifierInstruction(schedulingId); - } - - // Mark session as injected (one-time injection) - sessionInjected.add(key); - - api.logger.info(`dirigent: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`); - return { prependContext: identity + instruction + schedulingInstruction }; + registerBeforePromptBuildHook({ + api, + baseConfig: baseConfig as DirigentConfig, + sessionDecision, + sessionInjected, + policyState, + DECISION_TTL_MS, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + buildEndMarkerInstruction, + buildSchedulingIdentifierInstruction, + buildAgentIdentity, }); // Register slash commands for Discord @@ -955,150 +874,20 @@ export default { }); // Handle NO_REPLY detection before message write - 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 ?? {}))}`, - ); - - let 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; - if (msg) { - const role = msg.role as string | undefined; - if (role && role !== "assistant") 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 = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig; - ensurePolicyStateLoaded(api, live); - const policy = resolvePolicy(live, channelId, policyState.channelPolicies); - - const trimmed = content.trim(); - const isEmpty = trimmed.length === 0; - const isNoReply = /^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; - const wasNoReply = isEmpty || 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])}`, - ); - - // Wait identifier: agent wants a human reply → all agents go silent - 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) { - api.logger.info( - `dirigent: DEBUG NO_REPLY detected session=${key} wasAllowed=${wasAllowed}`, - ); - - 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; - } - - 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 (shouldDebugLog(live, channelId)) { - api.logger.info( - `dirigent: before_message_write all agents no-reply, going dormant - no handoff`, - ); - } - return; - } - - // Trigger moderator handoff message using scheduling identifier format - if (live.moderatorBotToken) { - 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) { - 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)}`); - } + registerBeforeMessageWriteHook({ + api, + baseConfig: baseConfig as DirigentConfig, + policyState, + sessionAllowed, + sessionChannelId, + sessionAccountId, + sessionTurnHandled, + ensurePolicyStateLoaded, + getLivePluginConfig, + shouldDebugLog, + ensureTurnOrder, + resolveDiscordUserId, + sendModeratorMessage, }); // Turn advance: when an agent sends a message, check if it signals end of turn