Problems fixed: 1. before_message_write treated empty content (isEmpty) as NO_REPLY. Tool-call-only assistant messages (thinking + toolCall, no text) had empty extracted text, causing false NO_REPLY detection. In single-agent channels this immediately set turn to dormant, blocking all subsequent responses for the entire model run. 2. Added explicit toolCall/tool_call/tool_use detection in before_message_write — skip turn processing for intermediate tool-call messages entirely. 3. no-reply-api/server.mjs: default model name changed from 'dirigent-no-reply-v1' to 'no-reply' to match the configured model id in openclaw.json, fixing model list discovery. Changes: - plugin/hooks/before-message-write.ts: toolCall detection + remove isEmpty - plugin/hooks/message-sent.ts: remove isEmpty from wasNoReply - no-reply-api/server.mjs: fix default model name - dist/dirigent/index.ts: same fixes applied to monolithic build - dist/no-reply-api/server.mjs: same model name fix
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
import { resolvePolicy, type DirigentConfig } from "../rules.js";
|
|
import { onSpeakerDone, setWaitingForHuman } from "../turn-manager.js";
|
|
import { extractDiscordChannelId, extractDiscordChannelIdFromSessionKey } from "../channel-resolver.js";
|
|
|
|
type DebugConfig = {
|
|
enableDebugLogs?: boolean;
|
|
debugLogChannelIds?: string[];
|
|
};
|
|
|
|
type MessageSentDeps = {
|
|
api: OpenClawPluginApi;
|
|
baseConfig: DirigentConfig;
|
|
policyState: { channelPolicies: Record<string, unknown> };
|
|
sessionChannelId: Map<string, string>;
|
|
sessionAccountId: Map<string, string>;
|
|
sessionTurnHandled: Set<string>;
|
|
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
|
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
|
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>;
|
|
};
|
|
|
|
export function registerMessageSentHook(deps: MessageSentDeps): void {
|
|
const {
|
|
api,
|
|
baseConfig,
|
|
policyState,
|
|
sessionChannelId,
|
|
sessionAccountId,
|
|
sessionTurnHandled,
|
|
ensurePolicyStateLoaded,
|
|
getLivePluginConfig,
|
|
resolveDiscordUserId,
|
|
sendModeratorMessage,
|
|
} = deps;
|
|
|
|
api.on("message_sent", async (event, ctx) => {
|
|
try {
|
|
const key = ctx.sessionKey;
|
|
const c = (ctx || {}) as Record<string, unknown>;
|
|
const e = (event || {}) as Record<string, unknown>;
|
|
|
|
api.logger.info(
|
|
`dirigent: DEBUG message_sent RAW ctxKeys=${JSON.stringify(Object.keys(c))} eventKeys=${JSON.stringify(Object.keys(e))} ` +
|
|
`ctx.channelId=${String(c.channelId ?? "undefined")} ctx.conversationId=${String(c.conversationId ?? "undefined")} ` +
|
|
`ctx.accountId=${String(c.accountId ?? "undefined")} event.to=${String(e.to ?? "undefined")} ` +
|
|
`session=${key ?? "undefined"}`,
|
|
);
|
|
|
|
let channelId = extractDiscordChannelId(c, e);
|
|
if (!channelId && key) {
|
|
channelId = sessionChannelId.get(key);
|
|
}
|
|
if (!channelId && key) {
|
|
channelId = extractDiscordChannelIdFromSessionKey(key);
|
|
}
|
|
const accountId = (ctx.accountId as string | undefined) || (key ? sessionAccountId.get(key) : undefined);
|
|
const content = (event.content as string) || "";
|
|
|
|
api.logger.info(
|
|
`dirigent: DEBUG message_sent RESOLVED session=${key ?? "undefined"} channelId=${channelId ?? "undefined"} accountId=${accountId ?? "undefined"} content=${content.slice(0, 100)}`,
|
|
);
|
|
|
|
if (!channelId || !accountId) return;
|
|
|
|
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
|
ensurePolicyStateLoaded(api, live);
|
|
const policy = resolvePolicy(live, channelId, policyState.channelPolicies as Record<string, any>);
|
|
|
|
const trimmed = content.trim();
|
|
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;
|
|
// Only treat explicit NO_REPLY/HEARTBEAT_OK keywords as no-reply.
|
|
const wasNoReply = isNoReply;
|
|
|
|
if (key && sessionTurnHandled.has(key)) {
|
|
sessionTurnHandled.delete(key);
|
|
api.logger.info(
|
|
`dirigent: message_sent skipping turn advance (already handled in before_message_write) session=${key} channel=${channelId}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (hasWaitIdentifier) {
|
|
setWaitingForHuman(channelId);
|
|
api.logger.info(
|
|
`dirigent: message_sent wait-for-human triggered channel=${channelId} from=${accountId}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (wasNoReply || hasEndSymbol) {
|
|
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
|
|
const trigger = wasNoReply ? "no_reply_keyword" : "end_symbol";
|
|
api.logger.info(
|
|
`dirigent: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
|
|
);
|
|
|
|
if (wasNoReply && nextSpeaker && live.moderatorBotToken) {
|
|
const nextUserId = resolveDiscordUserId(api, nextSpeaker);
|
|
if (nextUserId) {
|
|
const schedulingId = live.schedulingIdentifier || "➡️";
|
|
const handoffMsg = `<@${nextUserId}>${schedulingId}`;
|
|
await sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger);
|
|
} else {
|
|
api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
api.logger.warn(`dirigent: message_sent hook failed: ${String(err)}`);
|
|
}
|
|
});
|
|
}
|