refactor #22
@@ -86,19 +86,45 @@ export async function deleteMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a message then immediately delete it (used for schedule_identifier trigger). */
|
/**
|
||||||
export async function sendAndDelete(
|
* Per-channel last schedule-trigger message ID.
|
||||||
|
* Stored on globalThis so it survives VM-context hot-reloads.
|
||||||
|
* Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent.
|
||||||
|
*/
|
||||||
|
const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId";
|
||||||
|
if (!(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY]) {
|
||||||
|
(globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] = new Map<string, string>();
|
||||||
|
}
|
||||||
|
const lastTriggerMsgId: Map<string, string> = (globalThis as Record<string, unknown>)[_LAST_TRIGGER_KEY] as Map<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a schedule-identifier trigger message, then delete the PREVIOUS one for
|
||||||
|
* this channel (so only the current trigger is visible at any time).
|
||||||
|
*
|
||||||
|
* In debugMode the previous message is NOT deleted, leaving a full trigger
|
||||||
|
* history visible in Discord for inspection.
|
||||||
|
*/
|
||||||
|
export async function sendScheduleTrigger(
|
||||||
token: string,
|
token: string,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
content: string,
|
content: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
debugMode = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const prevMsgId = lastTriggerMsgId.get(channelId);
|
||||||
const result = await sendModeratorMessage(token, channelId, content, logger);
|
const result = await sendModeratorMessage(token, channelId, content, logger);
|
||||||
if (result.ok && result.messageId) {
|
if (!result.ok || !result.messageId) return;
|
||||||
// Small delay to ensure Discord has processed the message before deletion
|
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
if (!debugMode) {
|
||||||
await deleteMessage(token, channelId, result.messageId, logger);
|
// Track the new message so the NEXT call can delete it
|
||||||
|
lastTriggerMsgId.set(channelId, result.messageId);
|
||||||
|
// Delete the previous trigger with a small delay
|
||||||
|
if (prevMsgId) {
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
await deleteMessage(token, channelId, prevMsgId, logger);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// debugMode: don't track, don't delete — every trigger stays in history
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the latest message ID in a channel (for use as poll anchor). */
|
/** Get the latest message ID in a channel (for use as poll anchor). */
|
||||||
@@ -121,32 +147,45 @@ export async function getLatestMessageId(
|
|||||||
type DiscordMessage = { id: string; author: { id: string }; content: string };
|
type DiscordMessage = { id: string; author: { id: string }; content: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll the channel until a message from agentDiscordUserId with id > anchorId
|
* Poll the channel until any message from agentDiscordUserId with id > anchorId
|
||||||
* ends with the tail fingerprint.
|
* appears. The anchor is set in before_model_resolve just before the LLM call,
|
||||||
|
* so any agent message after it must belong to this turn.
|
||||||
*
|
*
|
||||||
* @returns the matching messageId, or undefined on timeout.
|
* Uses exponential back-off starting at initialPollMs, doubling each miss,
|
||||||
|
* capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid
|
||||||
|
* getting permanently stuck when the agent's Discord message is never delivered.
|
||||||
|
*
|
||||||
|
* @returns { matched: true } when found, { interrupted: true } when aborted,
|
||||||
|
* { matched: false, interrupted: false } on timeout.
|
||||||
*/
|
*/
|
||||||
export async function pollForTailMatch(opts: {
|
export async function pollForTailMatch(opts: {
|
||||||
token: string;
|
token: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
anchorId: string;
|
anchorId: string;
|
||||||
agentDiscordUserId: string;
|
agentDiscordUserId: string;
|
||||||
tailFingerprint: string;
|
/** Initial poll interval in ms (default 800). */
|
||||||
|
initialPollMs?: number;
|
||||||
|
/** Maximum poll interval in ms (default 8 000). */
|
||||||
|
maxPollMs?: number;
|
||||||
|
/** Give up and return after this many ms (default 30 000). */
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
pollIntervalMs?: number;
|
/** Callback checked before each poll; if true, polling is aborted. */
|
||||||
/** Callback checked each poll; if true, polling is aborted (interrupted). */
|
|
||||||
isInterrupted?: () => boolean;
|
isInterrupted?: () => boolean;
|
||||||
}): Promise<{ matched: boolean; interrupted: boolean }> {
|
}): Promise<{ matched: boolean; interrupted: boolean }> {
|
||||||
const {
|
const {
|
||||||
token, channelId, anchorId, agentDiscordUserId,
|
token, channelId, anchorId, agentDiscordUserId,
|
||||||
tailFingerprint, timeoutMs = 15000, pollIntervalMs = 800,
|
initialPollMs = 800,
|
||||||
|
maxPollMs = 8_000,
|
||||||
|
timeoutMs = 30_000,
|
||||||
isInterrupted = () => false,
|
isInterrupted = () => false,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
|
let interval = initialPollMs;
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (true) {
|
||||||
if (isInterrupted()) return { matched: false, interrupted: true };
|
if (isInterrupted()) return { matched: false, interrupted: true };
|
||||||
|
if (Date.now() >= deadline) return { matched: false, interrupted: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
@@ -155,25 +194,19 @@ export async function pollForTailMatch(opts: {
|
|||||||
);
|
);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const msgs = (await r.json()) as DiscordMessage[];
|
const msgs = (await r.json()) as DiscordMessage[];
|
||||||
const candidates = msgs.filter(
|
const found = msgs.some(
|
||||||
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
|
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
|
||||||
);
|
);
|
||||||
if (candidates.length > 0) {
|
if (found) return { matched: true, interrupted: false };
|
||||||
// Most recent is first in Discord's response
|
|
||||||
const latest = candidates[0];
|
|
||||||
if (latest.content.endsWith(tailFingerprint)) {
|
|
||||||
return { matched: true, interrupted: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore transient errors, keep polling
|
// ignore transient errors, keep polling
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
// Exponential back-off, capped at maxPollMs
|
||||||
|
interval = Math.min(interval * 2, maxPollMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { matched: false, interrupted: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
|
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ import {
|
|||||||
type SpeakerEntry,
|
type SpeakerEntry,
|
||||||
} from "../turn-manager.js";
|
} from "../turn-manager.js";
|
||||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||||
import { pollForTailMatch, sendAndDelete } from "../core/moderator-discord.js";
|
import { pollForTailMatch, sendScheduleTrigger } from "../core/moderator-discord.js";
|
||||||
|
|
||||||
const TAIL_LENGTH = 40;
|
|
||||||
const TAIL_MATCH_TIMEOUT_MS = 15_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process-level deduplication for agent_end events.
|
* Process-level deduplication for agent_end events.
|
||||||
@@ -33,6 +30,17 @@ if (!(globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY]) {
|
|||||||
}
|
}
|
||||||
const processedAgentEndRunIds: Set<string> = (globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] as Set<string>;
|
const processedAgentEndRunIds: Set<string> = (globalThis as Record<string, unknown>)[_AGENT_END_DEDUP_KEY] as Set<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-channel advance lock: prevents two concurrent agent_end handler instances
|
||||||
|
* (from different VM contexts with different runIds) from both calling advanceSpeaker
|
||||||
|
* for the same channel at the same time, which would double-advance the speaker index.
|
||||||
|
*/
|
||||||
|
const _ADVANCE_LOCK_KEY = "_dirigentAdvancingChannels";
|
||||||
|
if (!(globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY]) {
|
||||||
|
(globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY] = new Set<string>();
|
||||||
|
}
|
||||||
|
const advancingChannels: Set<string> = (globalThis as Record<string, unknown>)[_ADVANCE_LOCK_KEY] as Set<string>;
|
||||||
|
|
||||||
/** Extract plain text from agent_end event.messages last assistant entry. */
|
/** Extract plain text from agent_end event.messages last assistant entry. */
|
||||||
function extractFinalText(messages: unknown[]): string {
|
function extractFinalText(messages: unknown[]): string {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
@@ -55,7 +63,12 @@ function extractFinalText(messages: unknown[]): string {
|
|||||||
|
|
||||||
function isEmptyTurn(text: string): boolean {
|
function isEmptyTurn(text: string): boolean {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
return t === "" || /^NO$/i.test(t) || /^NO_REPLY$/i.test(t);
|
if (t === "") return true;
|
||||||
|
// Check if the last non-empty line is NO or NO_REPLY (agents often write
|
||||||
|
// explanatory text before the final answer on the last line).
|
||||||
|
const lines = t.split("\n").map((l) => l.trim()).filter((l) => l !== "");
|
||||||
|
const last = lines[lines.length - 1] ?? "";
|
||||||
|
return /^NO$/i.test(last) || /^NO_REPLY$/i.test(last);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentEndDeps = {
|
export type AgentEndDeps = {
|
||||||
@@ -64,6 +77,7 @@ export type AgentEndDeps = {
|
|||||||
identityRegistry: IdentityRegistry;
|
identityRegistry: IdentityRegistry;
|
||||||
moderatorBotToken: string | undefined;
|
moderatorBotToken: string | undefined;
|
||||||
scheduleIdentifier: string;
|
scheduleIdentifier: string;
|
||||||
|
debugMode: boolean;
|
||||||
/** Called when discussion channel enters dormant — to send idle reminder. */
|
/** Called when discussion channel enters dormant — to send idle reminder. */
|
||||||
onDiscussionDormant?: (channelId: string) => Promise<void>;
|
onDiscussionDormant?: (channelId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -72,7 +86,7 @@ export type AgentEndDeps = {
|
|||||||
export type InterruptFn = (channelId: string) => void;
|
export type InterruptFn = (channelId: string) => void;
|
||||||
|
|
||||||
export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
||||||
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionDormant } = deps;
|
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, onDiscussionDormant } = deps;
|
||||||
|
|
||||||
const interruptedChannels = new Set<string>();
|
const interruptedChannels = new Set<string>();
|
||||||
|
|
||||||
@@ -95,7 +109,7 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
async function triggerNextSpeaker(channelId: string, next: SpeakerEntry): Promise<void> {
|
async function triggerNextSpeaker(channelId: string, next: SpeakerEntry): Promise<void> {
|
||||||
if (!moderatorBotToken) return;
|
if (!moderatorBotToken) return;
|
||||||
const msg = `<@${next.discordUserId}>${scheduleIdentifier}`;
|
const msg = `<@${next.discordUserId}>${scheduleIdentifier}`;
|
||||||
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
|
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
|
||||||
api.logger.info(`dirigent: triggered next speaker agentId=${next.agentId} channel=${channelId}`);
|
api.logger.info(`dirigent: triggered next speaker agentId=${next.agentId} channel=${channelId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,51 +141,60 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
|
|
||||||
const agentId = ctx.agentId;
|
const agentId = ctx.agentId;
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
if (!isCurrentSpeaker(channelId, agentId)) return;
|
|
||||||
|
|
||||||
// Only process agent_ends for turns that were explicitly started by before_model_resolve.
|
|
||||||
// This prevents stale NO_REPLY completions (from initial suppression) from being counted.
|
|
||||||
if (!isTurnPending(channelId, agentId)) {
|
|
||||||
api.logger.info(`dirigent: agent_end skipping stale turn agentId=${agentId} channel=${channelId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume a blocked-pending slot if any exists. These are NO_REPLY completions
|
|
||||||
// from before_model_resolve blocking events (non-speaker or init-suppressed) that
|
|
||||||
// fire late — after the agent became the current speaker — due to history-building
|
|
||||||
// overhead (~10s). We skip them until the counter is exhausted, at which point
|
|
||||||
// the next agent_end is the real LLM response.
|
|
||||||
if (consumeBlockedPending(channelId, agentId)) {
|
|
||||||
api.logger.info(`dirigent: agent_end skipping blocked-pending NO_REPLY agentId=${agentId} channel=${channelId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTurnPending(channelId, agentId);
|
|
||||||
|
|
||||||
|
// Extract final text early so we can drain blocked_pending at every early-return point.
|
||||||
|
// This prevents the counter from staying inflated when stale NO_REPLYs are discarded
|
||||||
|
// by !isCurrentSpeaker or !isTurnPending without reaching consumeBlockedPending.
|
||||||
const messages = Array.isArray((event as Record<string, unknown>).messages)
|
const messages = Array.isArray((event as Record<string, unknown>).messages)
|
||||||
? ((event as Record<string, unknown>).messages as unknown[])
|
? ((event as Record<string, unknown>).messages as unknown[])
|
||||||
: [];
|
: [];
|
||||||
const finalText = extractFinalText(messages);
|
const finalText = extractFinalText(messages);
|
||||||
const empty = isEmptyTurn(finalText);
|
const empty = isEmptyTurn(finalText);
|
||||||
|
|
||||||
|
if (!isCurrentSpeaker(channelId, agentId)) {
|
||||||
|
// Drain blocked_pending for non-speaker stale NO_REPLYs. Without this, suppressions
|
||||||
|
// that happen while this agent is not the current speaker inflate the counter and cause
|
||||||
|
// its subsequent real empty turn to be misidentified as stale.
|
||||||
|
if (empty) consumeBlockedPending(channelId, agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process agent_ends for turns that were explicitly started by before_model_resolve.
|
||||||
|
// This prevents stale NO_REPLY completions (from initial suppression) from being counted.
|
||||||
|
if (!isTurnPending(channelId, agentId)) {
|
||||||
|
api.logger.info(`dirigent: agent_end skipping stale turn agentId=${agentId} channel=${channelId}`);
|
||||||
|
// Also drain here: stale may arrive after clearTurnPending but before markTurnStarted.
|
||||||
|
if (empty) consumeBlockedPending(channelId, agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume a blocked-pending slot only for self-wakeup stales: NO_REPLY completions
|
||||||
|
// from suppressed self-wakeup before_model_resolve calls that fire while the agent
|
||||||
|
// is current speaker with a turn already in progress.
|
||||||
|
if (empty && consumeBlockedPending(channelId, agentId)) {
|
||||||
|
api.logger.info(`dirigent: agent_end skipping blocked-pending NO_REPLY agentId=${agentId} channel=${channelId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTurnPending(channelId, agentId);
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
|
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!empty) {
|
if (!empty) {
|
||||||
// Real turn: wait for Discord delivery via tail-match polling
|
// Real turn: wait for Discord delivery before triggering next speaker.
|
||||||
|
// Anchor was set in before_model_resolve just before the LLM call, so any
|
||||||
|
// message from the agent after the anchor must be from this turn.
|
||||||
const identity = identityRegistry.findByAgentId(agentId);
|
const identity = identityRegistry.findByAgentId(agentId);
|
||||||
if (identity && moderatorBotToken) {
|
if (identity && moderatorBotToken) {
|
||||||
const anchorId = getAnchor(channelId, agentId) ?? "0";
|
const anchorId = getAnchor(channelId, agentId) ?? "0";
|
||||||
const tail = [...finalText].slice(-TAIL_LENGTH).join("");
|
|
||||||
|
|
||||||
const { matched, interrupted } = await pollForTailMatch({
|
const { matched: _matched, interrupted } = await pollForTailMatch({
|
||||||
token: moderatorBotToken,
|
token: moderatorBotToken,
|
||||||
channelId,
|
channelId,
|
||||||
anchorId,
|
anchorId,
|
||||||
agentDiscordUserId: identity.discordUserId,
|
agentDiscordUserId: identity.discordUserId,
|
||||||
tailFingerprint: tail,
|
|
||||||
timeoutMs: TAIL_MATCH_TIMEOUT_MS,
|
|
||||||
isInterrupted: () => interruptedChannels.has(channelId),
|
isInterrupted: () => interruptedChannels.has(channelId),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,26 +210,38 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
|||||||
// Fall through to normal advance so the turn cycle continues correctly.
|
// Fall through to normal advance so the turn cycle continues correctly.
|
||||||
api.logger.info(`dirigent: tail-match interrupted (non-dormant) channel=${channelId} — advancing normally`);
|
api.logger.info(`dirigent: tail-match interrupted (non-dormant) channel=${channelId} — advancing normally`);
|
||||||
}
|
}
|
||||||
if (!matched) {
|
|
||||||
api.logger.warn(`dirigent: tail-match timeout channel=${channelId} agentId=${agentId} — advancing anyway`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine shuffle mode from current list size
|
// Guard against concurrent advanceSpeaker calls for the same channel.
|
||||||
const debugBefore = getDebugInfo(channelId);
|
// Two VM context instances may both reach this point with different runIds
|
||||||
const currentListSize = debugBefore.exists ? (debugBefore.speakerList?.length ?? 0) : 0;
|
// (when the dedup Set doesn't catch them); only the first should advance.
|
||||||
const isShuffle = currentListSize > 2;
|
if (advancingChannels.has(channelId)) {
|
||||||
// In shuffle mode, pass agentId as previousLastAgentId so new list avoids it as first
|
api.logger.info(`dirigent: agent_end advance already in progress, skipping channel=${channelId} agentId=${agentId}`);
|
||||||
const previousLastAgentId = isShuffle ? agentId : undefined;
|
return;
|
||||||
|
}
|
||||||
|
advancingChannels.add(channelId);
|
||||||
|
|
||||||
const { next, enteredDormant } = await advanceSpeaker(
|
let next: ReturnType<typeof import("../turn-manager.js").getCurrentSpeaker> | null = null;
|
||||||
channelId,
|
let enteredDormant = false;
|
||||||
agentId,
|
try {
|
||||||
empty,
|
// Determine shuffle mode from current list size
|
||||||
() => buildSpeakerList(channelId),
|
const debugBefore = getDebugInfo(channelId);
|
||||||
previousLastAgentId,
|
const currentListSize = debugBefore.exists ? (debugBefore.speakerList?.length ?? 0) : 0;
|
||||||
);
|
const isShuffle = currentListSize > 2;
|
||||||
|
// In shuffle mode, pass agentId as previousLastAgentId so new list avoids it as first
|
||||||
|
const previousLastAgentId = isShuffle ? agentId : undefined;
|
||||||
|
|
||||||
|
({ next, enteredDormant } = await advanceSpeaker(
|
||||||
|
channelId,
|
||||||
|
agentId,
|
||||||
|
empty,
|
||||||
|
() => buildSpeakerList(channelId),
|
||||||
|
previousLastAgentId,
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
advancingChannels.delete(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
if (enteredDormant) {
|
if (enteredDormant) {
|
||||||
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
|
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import type { ChannelStore } from "../core/channel-store.js";
|
import type { ChannelStore } from "../core/channel-store.js";
|
||||||
import type { IdentityRegistry } from "../core/identity-registry.js";
|
import type { IdentityRegistry } from "../core/identity-registry.js";
|
||||||
import { isCurrentSpeaker, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
import { isCurrentSpeaker, isTurnPending, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
|
||||||
import { getLatestMessageId, sendAndDelete } from "../core/moderator-discord.js";
|
import { getLatestMessageId, sendScheduleTrigger } from "../core/moderator-discord.js";
|
||||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||||
|
|
||||||
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
|
/** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */
|
||||||
@@ -16,9 +16,10 @@ type Deps = {
|
|||||||
channelStore: ChannelStore;
|
channelStore: ChannelStore;
|
||||||
identityRegistry: IdentityRegistry;
|
identityRegistry: IdentityRegistry;
|
||||||
moderatorBotToken: string | undefined;
|
moderatorBotToken: string | undefined;
|
||||||
noReplyModel: string;
|
|
||||||
noReplyProvider: string;
|
|
||||||
scheduleIdentifier: string;
|
scheduleIdentifier: string;
|
||||||
|
debugMode: boolean;
|
||||||
|
noReplyProvider: string;
|
||||||
|
noReplyModel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,9 +35,7 @@ if (!(globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY]) {
|
|||||||
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
|
const processedBeforeModelResolveEvents: WeakSet<object> = (globalThis as Record<string, unknown>)[_BMR_DEDUP_KEY] as WeakSet<object>;
|
||||||
|
|
||||||
export function registerBeforeModelResolveHook(deps: Deps): void {
|
export function registerBeforeModelResolveHook(deps: Deps): void {
|
||||||
const { api, channelStore, identityRegistry, moderatorBotToken, noReplyModel, noReplyProvider, scheduleIdentifier } = deps;
|
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, debugMode, noReplyProvider, noReplyModel } = deps;
|
||||||
|
|
||||||
const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const;
|
|
||||||
|
|
||||||
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
|
/** Shared init lock — see turn-manager.ts getInitializingChannels(). */
|
||||||
const initializingChannels = getInitializingChannels();
|
const initializingChannels = getInitializingChannels();
|
||||||
@@ -57,19 +56,23 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
|
|||||||
|
|
||||||
const mode = channelStore.getMode(channelId);
|
const mode = channelStore.getMode(channelId);
|
||||||
|
|
||||||
// dead mode: suppress all responses
|
// dead/report mode: suppress all via no-reply model
|
||||||
if (mode === "report" || mode === "dead" as string) return NO_REPLY;
|
if (mode === "report" || mode === "dead" as string) {
|
||||||
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
// concluded discussion: suppress all agent responses (auto-reply handled by message_received)
|
|
||||||
if (mode === "discussion") {
|
|
||||||
const rec = channelStore.getRecord(channelId);
|
|
||||||
if (rec.discussion?.concluded) return NO_REPLY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// disabled modes: let agents respond freely
|
// concluded discussion: suppress via no-reply model
|
||||||
|
if (mode === "discussion") {
|
||||||
|
const rec = channelStore.getRecord(channelId);
|
||||||
|
if (rec.discussion?.concluded) {
|
||||||
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabled modes: no turn management
|
||||||
if (mode === "none" || mode === "work") return;
|
if (mode === "none" || mode === "work") return;
|
||||||
|
|
||||||
// discussion / chat: check turn
|
// chat / discussion (active): check turn
|
||||||
const agentId = ctx.agentId;
|
const agentId = ctx.agentId;
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
|
|
||||||
@@ -78,7 +81,9 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
|
|||||||
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
|
// Only one concurrent initializer per channel (Node.js single-threaded: this is safe)
|
||||||
if (initializingChannels.has(channelId)) {
|
if (initializingChannels.has(channelId)) {
|
||||||
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
|
api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`);
|
||||||
return NO_REPLY;
|
// incrementBlockedPending so agent_end knows to expect a stale NO_REPLY completion later
|
||||||
|
incrementBlockedPending(channelId, agentId);
|
||||||
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
}
|
}
|
||||||
initializingChannels.add(channelId);
|
initializingChannels.add(channelId);
|
||||||
try {
|
try {
|
||||||
@@ -95,12 +100,13 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
|
|||||||
const first = speakers[0];
|
const first = speakers[0];
|
||||||
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
|
api.logger.info(`dirigent: initialized speaker list channel=${channelId} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`);
|
||||||
|
|
||||||
// If this agent is NOT the first speaker, trigger first speaker and suppress this one
|
// If this agent is NOT the first speaker, trigger first speaker and suppress self
|
||||||
if (first.agentId !== agentId && moderatorBotToken) {
|
if (first.agentId !== agentId && moderatorBotToken) {
|
||||||
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
|
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
|
||||||
return NO_REPLY;
|
incrementBlockedPending(channelId, agentId);
|
||||||
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
}
|
}
|
||||||
// If this agent IS the first speaker, fall through to normal turn logic
|
// Fall through — this agent IS the first speaker
|
||||||
} else {
|
} else {
|
||||||
// No registered agents visible — let everyone respond freely
|
// No registered agents visible — let everyone respond freely
|
||||||
return;
|
return;
|
||||||
@@ -113,13 +119,25 @@ export function registerBeforeModelResolveHook(deps: Deps): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If channel is dormant: suppress all agents
|
// Channel is dormant: suppress via no-reply model
|
||||||
if (isDormant(channelId)) return NO_REPLY;
|
if (isDormant(channelId)) {
|
||||||
|
api.logger.info(`dirigent: before_model_resolve suppressing dormant agentId=${agentId} channel=${channelId}`);
|
||||||
|
incrementBlockedPending(channelId, agentId);
|
||||||
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
|
}
|
||||||
|
|
||||||
if (!isCurrentSpeaker(channelId, agentId)) {
|
if (!isCurrentSpeaker(channelId, agentId)) {
|
||||||
api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`);
|
api.logger.info(`dirigent: before_model_resolve suppressing non-speaker agentId=${agentId} channel=${channelId}`);
|
||||||
incrementBlockedPending(channelId, agentId);
|
incrementBlockedPending(channelId, agentId);
|
||||||
return NO_REPLY;
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a turn is already in progress for this agent, this is a duplicate wakeup
|
||||||
|
// (e.g. agent woke itself via a message-tool send). Suppress it.
|
||||||
|
if (isTurnPending(channelId, agentId)) {
|
||||||
|
api.logger.info(`dirigent: before_model_resolve turn already in progress, suppressing self-wakeup agentId=${agentId} channel=${channelId}`);
|
||||||
|
incrementBlockedPending(channelId, agentId);
|
||||||
|
return { modelOverride: noReplyModel, providerOverride: noReplyProvider };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
|
// Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions)
|
||||||
|
|||||||
Reference in New Issue
Block a user