Files
Dirigent/plugin/hooks/before-model-resolve.ts

174 lines
6.1 KiB
TypeScript

import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js";
import { checkTurn } from "../turn-manager.js";
import { deriveDecisionInputFromPrompt } from "../decision-input.js";
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
type BeforeModelResolveDeps = {
api: OpenClawPluginApi;
baseConfig: DirigentConfig;
sessionDecision: Map<string, DecisionRecord>;
sessionAllowed: Map<string, boolean>;
sessionChannelId: Map<string, string>;
sessionAccountId: Map<string, string>;
forceNoReplySessions: Set<string>;
policyState: { channelPolicies: Record<string, unknown> };
DECISION_TTL_MS: number;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
pruneDecisionMap: () => void;
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
isMultiMessageMode: (channelId: string) => boolean;
discussionService?: {
isClosedDiscussion: (channelId: string) => boolean;
};
};
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
const {
api,
baseConfig,
sessionDecision,
sessionAllowed,
sessionChannelId,
sessionAccountId,
forceNoReplySessions,
policyState,
DECISION_TTL_MS,
ensurePolicyStateLoaded,
resolveAccountId,
pruneDecisionMap,
shouldDebugLog,
ensureTurnOrder,
isMultiMessageMode,
discussionService,
} = deps;
api.on("before_model_resolve", async (event, ctx) => {
const key = ctx.sessionKey;
if (!key) return;
const live = baseConfig as DirigentConfig & DebugConfig;
ensurePolicyStateLoaded(api, live);
if (forceNoReplySessions.has(key)) {
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
}
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
if (live.enableDebugLogs) {
api.logger.info(
`dirigent: DEBUG_BEFORE_MODEL_RESOLVE ctx=${JSON.stringify({ sessionKey: ctx.sessionKey, messageProvider: ctx.messageProvider, agentId: ctx.agentId })} ` +
`promptPreview=${prompt.slice(0, 300)}`,
);
}
const derived = deriveDecisionInputFromPrompt({
prompt,
messageProvider: ctx.messageProvider,
sessionKey: key,
ctx: ctx as Record<string, unknown>,
event: event as Record<string, unknown>,
});
const hasConvMarker = prompt.includes("Conversation info (untrusted metadata):");
if (live.discordOnly !== false && (!hasConvMarker || derived.channel !== "discord")) return;
if (derived.channelId) {
sessionChannelId.set(key, derived.channelId);
if (discussionService?.isClosedDiscussion(derived.channelId)) {
sessionAllowed.set(key, false);
api.logger.info(`dirigent: before_model_resolve forcing no-reply for closed discussion channel=${derived.channelId} session=${key}`);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
}
if (isMultiMessageMode(derived.channelId)) {
sessionAllowed.set(key, false);
api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
}
}
const resolvedAccountId = resolveAccountId(api, ctx.agentId || "");
if (resolvedAccountId) {
sessionAccountId.set(key, resolvedAccountId);
}
let rec = sessionDecision.get(key);
if (!rec || Date.now() - rec.createdAt > DECISION_TTL_MS) {
if (rec) sessionDecision.delete(key);
const decision = evaluateDecision({
config: live,
channel: derived.channel,
channelId: derived.channelId,
channelPolicies: policyState.channelPolicies as Record<string, any>,
senderId: derived.senderId,
content: derived.content,
});
rec = { decision, createdAt: Date.now() };
sessionDecision.set(key, rec);
pruneDecisionMap();
if (shouldDebugLog(live, derived.channelId)) {
api.logger.info(
`dirigent: debug before_model_resolve recompute session=${key} ` +
`channel=${derived.channel} channelId=${derived.channelId ?? ""} senderId=${derived.senderId ?? ""} ` +
`convSenderId=${String((derived.conv as Record<string, unknown>).sender_id ?? "")} ` +
`convSender=${String((derived.conv as Record<string, unknown>).sender ?? "")} ` +
`convChannelId=${String((derived.conv as Record<string, unknown>).channel_id ?? "")} ` +
`decision=${decision.reason} shouldNoReply=${decision.shouldUseNoReply} shouldInject=${decision.shouldInjectEndMarkerPrompt}`,
);
}
}
if (derived.channelId) {
await ensureTurnOrder(api, derived.channelId);
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId);
if (!turnCheck.allowed) {
sessionAllowed.set(key, false);
api.logger.info(
`dirigent: before_model_resolve blocking out-of-turn speaker session=${key} channel=${derived.channelId} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker}`,
);
return {
model: ctx.model,
provider: ctx.provider,
noReply: true,
};
}
sessionAllowed.set(key, true);
}
}
if (!rec.decision.shouldUseNoReply) return;
const out: Record<string, unknown> = { noReply: true };
if (rec.decision.provider) out.provider = rec.decision.provider;
if (rec.decision.model) out.model = rec.decision.model;
return out;
});
}