When the turn manager determines it's an agent's turn (checkTurn.allowed), the rules engine's evaluateDecision() could still override the model to no-reply with reason 'rule_match_no_end_symbol'. This happened because: 1. The sender of the triggering message (another agent) was not in the humanList, so the rules fell through to the end-symbol check. 2. getLastChar() operates on the full prompt (including system content like Runtime info), so it never found the end symbol even when the actual message ended with one. Fix: return early from before_model_resolve after the turn check passes, skipping the rules-based no-reply override entirely. The turn manager is the authoritative source for multi-agent turn coordination. Tested: 3-agent counting chain ran successfully (3→11) with correct NO_REPLY handling when count exceeded threshold.
173 lines
6.3 KiB
TypeScript
173 lines
6.3 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>;
|
|
policyState: { channelPolicies: Record<string, unknown> };
|
|
DECISION_TTL_MS: number;
|
|
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
|
|
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
|
|
resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined;
|
|
pruneDecisionMap: () => void;
|
|
shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean;
|
|
ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise<void> | void;
|
|
};
|
|
|
|
export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void {
|
|
const {
|
|
api,
|
|
baseConfig,
|
|
sessionDecision,
|
|
sessionAllowed,
|
|
sessionChannelId,
|
|
sessionAccountId,
|
|
policyState,
|
|
DECISION_TTL_MS,
|
|
ensurePolicyStateLoaded,
|
|
getLivePluginConfig,
|
|
resolveAccountId,
|
|
pruneDecisionMap,
|
|
shouldDebugLog,
|
|
ensureTurnOrder,
|
|
} = deps;
|
|
|
|
api.on("before_model_resolve", async (event, ctx) => {
|
|
const key = ctx.sessionKey;
|
|
if (!key) return;
|
|
|
|
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & DebugConfig;
|
|
ensurePolicyStateLoaded(api, live);
|
|
|
|
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);
|
|
}
|
|
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: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
|
);
|
|
return {
|
|
providerOverride: live.noReplyProvider,
|
|
modelOverride: live.noReplyModel,
|
|
};
|
|
}
|
|
sessionAllowed.set(key, true);
|
|
api.logger.info(
|
|
`dirigent: turn allowed, skipping rules override session=${key} accountId=${accountId}`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!rec.decision.shouldUseNoReply) {
|
|
if (rec.needsRestore) {
|
|
sessionDecision.delete(key);
|
|
return {
|
|
providerOverride: undefined,
|
|
modelOverride: undefined,
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
|
|
rec.needsRestore = true;
|
|
sessionDecision.set(key, rec);
|
|
|
|
if (live.enableDebugLogs) {
|
|
const hasConvMarker2 = prompt.includes("Conversation info (untrusted metadata):");
|
|
api.logger.info(
|
|
`dirigent: DEBUG_NO_REPLY_TRIGGER 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 ?? "")} ` +
|
|
`decision=${rec.decision.reason} ` +
|
|
`shouldNoReply=${rec.decision.shouldUseNoReply} shouldInject=${rec.decision.shouldInjectEndMarkerPrompt} ` +
|
|
`hasConvMarker=${hasConvMarker2} promptLen=${prompt.length}`,
|
|
);
|
|
}
|
|
|
|
api.logger.info(
|
|
`dirigent: override model for session=${key}, provider=${live.noReplyProvider}, model=${live.noReplyModel}, reason=${rec.decision.reason}`,
|
|
);
|
|
|
|
return {
|
|
providerOverride: live.noReplyProvider,
|
|
modelOverride: live.noReplyModel,
|
|
};
|
|
});
|
|
}
|