213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
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<string, unknown> };
|
|
sessionAllowed: Map<string, boolean>;
|
|
sessionChannelId: Map<string, string>;
|
|
sessionAccountId: Map<string, string>;
|
|
sessionTurnHandled: Set<string>;
|
|
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
|
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
|
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | 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<void>;
|
|
discussionService?: {
|
|
maybeSendIdleReminder: (channelId: string) => Promise<void>;
|
|
getDiscussion: (channelId: string) => { status: string } | undefined;
|
|
};
|
|
};
|
|
|
|
export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): void {
|
|
const {
|
|
api,
|
|
baseConfig,
|
|
policyState,
|
|
sessionAllowed,
|
|
sessionChannelId,
|
|
sessionAccountId,
|
|
sessionTurnHandled,
|
|
ensurePolicyStateLoaded,
|
|
shouldDebugLog,
|
|
ensureTurnOrder,
|
|
resolveDiscordUserId,
|
|
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<string, unknown>).message as Record<string, unknown> | 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<string, unknown>[]).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<string, unknown>).text === "string") {
|
|
content += (part as Record<string, unknown>).text;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!content) {
|
|
content = ((event as Record<string, unknown>).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<string, any>);
|
|
|
|
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) {
|
|
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)}`);
|
|
}
|
|
});
|
|
}
|