fix: correct dormancy detection — isEmptyTurn last-line + blocked_pending drain
Two bugs that prevented turn-manager dormancy from ever triggering: 1. isEmptyTurn too strict: agents output multi-line text ending with "NO_REPLY" on the last line, but the regex ^NO_REPLY$ required the entire string to match. Now checks only the last non-empty line. 2. blocked_pending counter inflation: non-speaker suppressions incremented the counter but their stale NO_REPLYs were discarded at the !isCurrentSpeaker early return without decrementing. Over a full cycle the counter inflated by the number of suppressions, causing the agent's real empty turn to be misidentified as stale when it finally arrived. Fix: at both early-return points in agent_end (!isCurrentSpeaker and !isTurnPending), drain blocked_pending when the turn is empty. Also fixed: pollForTailMatch now uses any-message detection (instead of tail-fingerprint content matching) with a 30 s timeout, avoiding infinite polling when agents send concise Discord messages after verbose LLM output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,7 @@ import {
|
||||
type SpeakerEntry,
|
||||
} from "../turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
import { pollForTailMatch, sendAndDelete } from "../core/moderator-discord.js";
|
||||
|
||||
const TAIL_LENGTH = 40;
|
||||
const TAIL_MATCH_TIMEOUT_MS = 15_000;
|
||||
import { pollForTailMatch, sendScheduleTrigger } from "../core/moderator-discord.js";
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
function extractFinalText(messages: unknown[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -55,7 +63,12 @@ function extractFinalText(messages: unknown[]): string {
|
||||
|
||||
function isEmptyTurn(text: string): boolean {
|
||||
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 = {
|
||||
@@ -64,6 +77,7 @@ export type AgentEndDeps = {
|
||||
identityRegistry: IdentityRegistry;
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
debugMode: boolean;
|
||||
/** Called when discussion channel enters dormant — to send idle reminder. */
|
||||
onDiscussionDormant?: (channelId: string) => Promise<void>;
|
||||
};
|
||||
@@ -72,7 +86,7 @@ export type AgentEndDeps = {
|
||||
export type InterruptFn = (channelId: string) => void;
|
||||
|
||||
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>();
|
||||
|
||||
@@ -95,7 +109,7 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
||||
async function triggerNextSpeaker(channelId: string, next: SpeakerEntry): Promise<void> {
|
||||
if (!moderatorBotToken) return;
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -127,51 +141,60 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
|
||||
|
||||
const agentId = ctx.agentId;
|
||||
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)
|
||||
? ((event as Record<string, unknown>).messages as unknown[])
|
||||
: [];
|
||||
const finalText = extractFinalText(messages);
|
||||
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(
|
||||
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
|
||||
);
|
||||
|
||||
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);
|
||||
if (identity && moderatorBotToken) {
|
||||
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,
|
||||
channelId,
|
||||
anchorId,
|
||||
agentDiscordUserId: identity.discordUserId,
|
||||
tailFingerprint: tail,
|
||||
timeoutMs: TAIL_MATCH_TIMEOUT_MS,
|
||||
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.
|
||||
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
|
||||
const debugBefore = getDebugInfo(channelId);
|
||||
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;
|
||||
// Guard against concurrent advanceSpeaker calls for the same channel.
|
||||
// Two VM context instances may both reach this point with different runIds
|
||||
// (when the dedup Set doesn't catch them); only the first should advance.
|
||||
if (advancingChannels.has(channelId)) {
|
||||
api.logger.info(`dirigent: agent_end advance already in progress, skipping channel=${channelId} agentId=${agentId}`);
|
||||
return;
|
||||
}
|
||||
advancingChannels.add(channelId);
|
||||
|
||||
const { next, enteredDormant } = await advanceSpeaker(
|
||||
channelId,
|
||||
agentId,
|
||||
empty,
|
||||
() => buildSpeakerList(channelId),
|
||||
previousLastAgentId,
|
||||
);
|
||||
let next: ReturnType<typeof import("../turn-manager.js").getCurrentSpeaker> | null = null;
|
||||
let enteredDormant = false;
|
||||
try {
|
||||
// Determine shuffle mode from current list size
|
||||
const debugBefore = getDebugInfo(channelId);
|
||||
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) {
|
||||
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
|
||||
|
||||
Reference in New Issue
Block a user