feat: rewrite plugin as v2 with globalThis-based turn management
Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,455 +1,268 @@
|
||||
/**
|
||||
* Turn-based speaking manager for group channels.
|
||||
* Turn Manager (v2)
|
||||
*
|
||||
* Rules:
|
||||
* - Humans (humanList) are never in the turn order
|
||||
* - Turn order is auto-populated from channel/server members minus humans
|
||||
* - currentSpeaker can be null (dormant state)
|
||||
* - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null)
|
||||
* - Dormant → any new message reactivates:
|
||||
* - If sender is NOT in turn order → current = first in list
|
||||
* - If sender IS in turn order → current = next after sender
|
||||
* Per-channel state machine governing who speaks when.
|
||||
* Called from before_model_resolve (check turn) and agent_end (advance turn).
|
||||
*/
|
||||
|
||||
import { getChannelShuffling, isMultiMessageMode, markLastShuffled } from "./core/channel-modes.js";
|
||||
|
||||
export type ChannelTurnState = {
|
||||
/** Ordered accountIds for this channel (auto-populated, shuffled) */
|
||||
turnOrder: string[];
|
||||
/** Current speaker accountId, or null if dormant */
|
||||
currentSpeaker: string | null;
|
||||
/** Set of accountIds that have NO_REPLY'd this cycle */
|
||||
noRepliedThisCycle: Set<string>;
|
||||
/** Timestamp of last state change */
|
||||
lastChangedAt: number;
|
||||
// ── Mention override state ──
|
||||
/** Original turn order saved when override is active */
|
||||
savedTurnOrder?: string[];
|
||||
/** First agent in override cycle; used to detect cycle completion */
|
||||
overrideFirstAgent?: string;
|
||||
// ── Wait-for-human state ──
|
||||
/** When true, an agent used the wait identifier — all agents should stay silent until a human speaks */
|
||||
waitingForHuman: boolean;
|
||||
export type SpeakerEntry = {
|
||||
agentId: string;
|
||||
discordUserId: string;
|
||||
};
|
||||
|
||||
const channelTurns = new Map<string, ChannelTurnState>();
|
||||
|
||||
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
|
||||
const TURN_TIMEOUT_MS = 60_000;
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function reshuffleTurnOrder(channelId: string, currentOrder: string[], lastSpeaker?: string): string[] {
|
||||
const shufflingEnabled = getChannelShuffling(channelId);
|
||||
if (!shufflingEnabled) return currentOrder;
|
||||
|
||||
const shuffled = shuffleArray(currentOrder);
|
||||
|
||||
// If there's a last speaker and they're in the order, ensure they're not first
|
||||
if (lastSpeaker && shuffled.length > 1 && shuffled[0] === lastSpeaker) {
|
||||
// Find another speaker to swap with
|
||||
for (let i = 1; i < shuffled.length; i++) {
|
||||
if (shuffled[i] !== lastSpeaker) {
|
||||
[shuffled[0], shuffled[i]] = [shuffled[i], shuffled[0]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
// --- public API ---
|
||||
type ChannelTurnState = {
|
||||
speakerList: SpeakerEntry[];
|
||||
currentIndex: number;
|
||||
/** Tracks which agents sent empty turns in the current cycle. */
|
||||
emptyThisCycle: Set<string>;
|
||||
/** Tracks which agents completed a turn at all this cycle. */
|
||||
completedThisCycle: Set<string>;
|
||||
dormant: boolean;
|
||||
/** Discord message ID recorded at before_model_resolve, used as poll anchor. */
|
||||
anchorMessageId: Map<string, string>; // agentId → messageId
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize or update the turn order for a channel.
|
||||
* Called with the list of bot accountIds (already filtered, humans excluded).
|
||||
* All mutable state is stored on globalThis so it persists across VM-context
|
||||
* hot-reloads within the same gateway process. OpenClaw re-imports this module
|
||||
* in a fresh isolated VM context on each reload, but all contexts share the real
|
||||
* globalThis object because they run in the same Node.js process.
|
||||
*/
|
||||
export function initTurnOrder(channelId: string, botAccountIds: string[]): void {
|
||||
const existing = channelTurns.get(channelId);
|
||||
if (existing) {
|
||||
// Compare membership against base order.
|
||||
// If mention override is active, turnOrder is temporary; use savedTurnOrder for stable comparison.
|
||||
const baseOrder = existing.savedTurnOrder || existing.turnOrder;
|
||||
const oldSet = new Set(baseOrder);
|
||||
const newSet = new Set(botAccountIds);
|
||||
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
|
||||
if (same) return; // no change
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder membership-changed channel=${channelId} ` +
|
||||
`oldOrder=${JSON.stringify(existing.turnOrder)} oldCurrent=${existing.currentSpeaker} ` +
|
||||
`oldOverride=${JSON.stringify(existing.savedTurnOrder || null)} newMembers=${JSON.stringify(botAccountIds)}`,
|
||||
);
|
||||
function channelStates(): Map<string, ChannelTurnState> {
|
||||
if (!(_G._tmChannelStates instanceof Map)) _G._tmChannelStates = new Map<string, ChannelTurnState>();
|
||||
return _G._tmChannelStates as Map<string, ChannelTurnState>;
|
||||
}
|
||||
|
||||
const nextOrder = shuffleArray(botAccountIds);
|
||||
function pendingTurns(): Set<string> {
|
||||
if (!(_G._tmPendingTurns instanceof Set)) _G._tmPendingTurns = new Set<string>();
|
||||
return _G._tmPendingTurns as Set<string>;
|
||||
}
|
||||
|
||||
// Mention override active: update only the saved base order.
|
||||
// Keep temporary turnOrder/currentSpeaker intact so @mention routing is not clobbered.
|
||||
if (existing.savedTurnOrder) {
|
||||
existing.savedTurnOrder = nextOrder;
|
||||
existing.lastChangedAt = Date.now();
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied-base-only channel=${channelId} ` +
|
||||
`savedOrder=${JSON.stringify(nextOrder)} keptOverrideOrder=${JSON.stringify(existing.turnOrder)} ` +
|
||||
`keptCurrent=${existing.currentSpeaker}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-mention flow: preserve previous behavior (re-init to dormant).
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: nextOrder,
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
waitingForHuman: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder first-init channel=${channelId} members=${JSON.stringify(botAccountIds)}`,
|
||||
);
|
||||
|
||||
const nextOrder = shuffleArray(botAccountIds);
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: nextOrder,
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
waitingForHuman: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] initTurnOrder applied channel=${channelId} newOrder=${JSON.stringify(nextOrder)} newCurrent=null`,
|
||||
);
|
||||
function blockedPendingCounts(): Map<string, number> {
|
||||
if (!(_G._tmBlockedPendingCounts instanceof Map)) _G._tmBlockedPendingCounts = new Map<string, number>();
|
||||
return _G._tmBlockedPendingCounts as Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given accountId is allowed to speak.
|
||||
* Shared initialization lock: prevents multiple concurrent VM contexts from
|
||||
* simultaneously initializing the same channel's speaker list.
|
||||
* Used by both before_model_resolve and message_received hooks.
|
||||
*/
|
||||
export function checkTurn(channelId: string, accountId: string): {
|
||||
allowed: boolean;
|
||||
currentSpeaker: string | null;
|
||||
reason: string;
|
||||
} {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) {
|
||||
return { allowed: true, currentSpeaker: null, reason: "no_turn_state" };
|
||||
}
|
||||
export function getInitializingChannels(): Set<string> {
|
||||
if (!(_G._tmInitializingChannels instanceof Set)) _G._tmInitializingChannels = new Set<string>();
|
||||
return _G._tmInitializingChannels as Set<string>;
|
||||
}
|
||||
|
||||
// Waiting for human → block all agents
|
||||
if (state.waitingForHuman) {
|
||||
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
|
||||
}
|
||||
export function markTurnStarted(channelId: string, agentId: string): void {
|
||||
pendingTurns().add(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
// Not in turn order (human or unknown) → always allowed
|
||||
if (!state.turnOrder.includes(accountId)) {
|
||||
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" };
|
||||
}
|
||||
export function isTurnPending(channelId: string, agentId: string): boolean {
|
||||
return pendingTurns().has(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
// Dormant → not allowed (will be activated by onNewMessage)
|
||||
if (state.currentSpeaker === null) {
|
||||
return { allowed: false, currentSpeaker: null, reason: "dormant" };
|
||||
}
|
||||
|
||||
// Check timeout → auto-advance
|
||||
if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) {
|
||||
advanceTurn(channelId);
|
||||
// Re-check after advance
|
||||
const updated = channelTurns.get(channelId)!;
|
||||
if (updated.currentSpeaker === accountId) {
|
||||
return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" };
|
||||
}
|
||||
return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" };
|
||||
}
|
||||
|
||||
if (accountId === state.currentSpeaker) {
|
||||
return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" };
|
||||
}
|
||||
|
||||
return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" };
|
||||
export function clearTurnPending(channelId: string, agentId: string): void {
|
||||
pendingTurns().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new message arrives in the channel.
|
||||
* Handles reactivation from dormant state and human-triggered resets.
|
||||
*
|
||||
* NOTE: For human messages with @mentions, call setMentionOverride() instead.
|
||||
*
|
||||
* @param senderAccountId - the accountId of the message sender (could be human/bot/unknown)
|
||||
* @param isHuman - whether the sender is in the humanList
|
||||
* Counts NO_REPLY completions currently in-flight for an agent that was
|
||||
* blocked (non-speaker or init-suppressed). These completions take ~10s to
|
||||
* arrive (history-building overhead) and may arrive after markTurnStarted,
|
||||
* causing false empty-turn detection. We count them and skip one per agent_end
|
||||
* until the count reaches zero, at which point the next agent_end is real.
|
||||
*/
|
||||
export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return;
|
||||
|
||||
// Check for multi-message mode exit condition
|
||||
if (isMultiMessageMode(channelId) && isHuman) {
|
||||
// In multi-message mode, human messages don't trigger turn activation
|
||||
// We only exit multi-message mode if the end marker is detected in a higher-level hook
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
// Human message: clear wait-for-human, restore original order if overridden, activate from first
|
||||
state.waitingForHuman = false;
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.waitingForHuman) {
|
||||
// Waiting for human — ignore non-human messages
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentSpeaker !== null) {
|
||||
// Already active, no change needed from incoming message
|
||||
return;
|
||||
}
|
||||
|
||||
// Dormant state + non-human message → reactivate
|
||||
if (senderAccountId && state.turnOrder.includes(senderAccountId)) {
|
||||
// Sender is in turn order → next after sender
|
||||
const idx = state.turnOrder.indexOf(senderAccountId);
|
||||
const nextIdx = (idx + 1) % state.turnOrder.length;
|
||||
state.currentSpeaker = state.turnOrder[nextIdx];
|
||||
} else {
|
||||
// Sender not in turn order → start from first
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
}
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
export function incrementBlockedPending(channelId: string, agentId: string): void {
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
bpc.set(key, (bpc.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original turn order if an override is active.
|
||||
*/
|
||||
function restoreOriginalOrder(state: ChannelTurnState): void {
|
||||
if (state.savedTurnOrder) {
|
||||
state.turnOrder = state.savedTurnOrder;
|
||||
state.savedTurnOrder = undefined;
|
||||
state.overrideFirstAgent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a temporary mention override for the turn order.
|
||||
* When a human @mentions specific agents, only those agents speak (in their
|
||||
* relative order from the current turn order). After the cycle returns to the
|
||||
* first agent, the original order is restored.
|
||||
*
|
||||
* @param channelId - Discord channel ID
|
||||
* @param mentionedAccountIds - accountIds of @mentioned agents, ordered by
|
||||
* their position in the current turn order
|
||||
* @returns true if override was set, false if no valid agents
|
||||
*/
|
||||
export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || mentionedAccountIds.length === 0) return false;
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride start channel=${channelId} ` +
|
||||
`mentioned=${JSON.stringify(mentionedAccountIds)} current=${state.currentSpeaker} ` +
|
||||
`order=${JSON.stringify(state.turnOrder)} saved=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
// Restore any existing override first
|
||||
restoreOriginalOrder(state);
|
||||
|
||||
// Filter to agents actually in the turn order
|
||||
const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id));
|
||||
if (validIds.length === 0) {
|
||||
console.log(`[dirigent][turn-debug] setMentionOverride ignored channel=${channelId} reason=no-valid-mentioned`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Order by their position in the current turn order
|
||||
validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b));
|
||||
|
||||
// Save original and apply override
|
||||
state.savedTurnOrder = [...state.turnOrder];
|
||||
state.turnOrder = validIds;
|
||||
state.overrideFirstAgent = validIds[0];
|
||||
state.currentSpeaker = validIds[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
|
||||
console.log(
|
||||
`[dirigent][turn-debug] setMentionOverride applied channel=${channelId} ` +
|
||||
`overrideOrder=${JSON.stringify(state.turnOrder)} current=${state.currentSpeaker} ` +
|
||||
`savedOriginal=${JSON.stringify(state.savedTurnOrder || null)}`,
|
||||
);
|
||||
|
||||
/** Returns true (and decrements) if this agent_end should be treated as a stale blocked completion. */
|
||||
export function consumeBlockedPending(channelId: string, agentId: string): boolean {
|
||||
const bpc = blockedPendingCounts();
|
||||
const key = `${channelId}:${agentId}`;
|
||||
const count = bpc.get(key) ?? 0;
|
||||
if (count <= 0) return false;
|
||||
bpc.set(key, count - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mention override is currently active.
|
||||
*/
|
||||
export function hasMentionOverride(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.savedTurnOrder;
|
||||
export function resetBlockedPending(channelId: string, agentId: string): void {
|
||||
blockedPendingCounts().delete(`${channelId}:${agentId}`);
|
||||
}
|
||||
|
||||
function getState(channelId: string): ChannelTurnState | undefined {
|
||||
return channelStates().get(channelId);
|
||||
}
|
||||
|
||||
function ensureState(channelId: string): ChannelTurnState {
|
||||
const cs = channelStates();
|
||||
let s = cs.get(channelId);
|
||||
if (!s) {
|
||||
s = {
|
||||
speakerList: [],
|
||||
currentIndex: 0,
|
||||
emptyThisCycle: new Set(),
|
||||
completedThisCycle: new Set(),
|
||||
dormant: false,
|
||||
anchorMessageId: new Map(),
|
||||
};
|
||||
cs.set(channelId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Replace the speaker list (called at cycle boundaries and on init). */
|
||||
export function setSpeakerList(channelId: string, speakers: SpeakerEntry[]): void {
|
||||
const s = ensureState(channelId);
|
||||
s.speakerList = speakers;
|
||||
s.currentIndex = 0;
|
||||
}
|
||||
|
||||
/** Get the currently active speaker, or null if dormant / list empty. */
|
||||
export function getCurrentSpeaker(channelId: string): SpeakerEntry | null {
|
||||
const s = getState(channelId);
|
||||
if (!s || s.dormant || s.speakerList.length === 0) return null;
|
||||
return s.speakerList[s.currentIndex] ?? null;
|
||||
}
|
||||
|
||||
/** Check if a given agentId is the current speaker. */
|
||||
export function isCurrentSpeaker(channelId: string, agentId: string): boolean {
|
||||
const speaker = getCurrentSpeaker(channelId);
|
||||
return speaker?.agentId === agentId;
|
||||
}
|
||||
|
||||
/** Record the Discord anchor message ID for an agent's upcoming turn. */
|
||||
export function setAnchor(channelId: string, agentId: string, messageId: string): void {
|
||||
const s = ensureState(channelId);
|
||||
s.anchorMessageId.set(agentId, messageId);
|
||||
}
|
||||
|
||||
export function getAnchor(channelId: string, agentId: string): string | undefined {
|
||||
return getState(channelId)?.anchorMessageId.get(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the channel to "waiting for human" state.
|
||||
* All agents will be routed to no-reply until a human sends a message.
|
||||
* Advance the speaker after a turn completes.
|
||||
* Returns the new current speaker (or null if dormant).
|
||||
*
|
||||
* @param isEmpty - whether the completed turn was an empty turn
|
||||
* @param rebuildFn - async function that fetches current Discord members and
|
||||
* returns a new SpeakerEntry[]. Called at cycle boundaries.
|
||||
* @param previousLastAgentId - for shuffle mode: the last speaker of the
|
||||
* previous cycle (cannot become the new first speaker).
|
||||
*/
|
||||
export function setWaitingForHuman(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return;
|
||||
state.waitingForHuman = true;
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
export async function advanceSpeaker(
|
||||
channelId: string,
|
||||
agentId: string,
|
||||
isEmpty: boolean,
|
||||
rebuildFn: () => Promise<SpeakerEntry[]>,
|
||||
previousLastAgentId?: string,
|
||||
): Promise<{ next: SpeakerEntry | null; enteredDormant: boolean }> {
|
||||
const s = ensureState(channelId);
|
||||
|
||||
// Record this turn
|
||||
s.completedThisCycle.add(agentId);
|
||||
if (isEmpty) s.emptyThisCycle.add(agentId);
|
||||
|
||||
const wasLastInCycle = s.currentIndex >= s.speakerList.length - 1;
|
||||
|
||||
if (!wasLastInCycle) {
|
||||
// Middle of cycle — just advance pointer
|
||||
s.currentIndex++;
|
||||
s.dormant = false;
|
||||
return { next: s.speakerList[s.currentIndex] ?? null, enteredDormant: false };
|
||||
}
|
||||
|
||||
// === Cycle boundary ===
|
||||
const newSpeakers = await rebuildFn();
|
||||
const previousAgentIds = new Set(s.speakerList.map((sp) => sp.agentId));
|
||||
const hasNewAgents = newSpeakers.some((sp) => !previousAgentIds.has(sp.agentId));
|
||||
|
||||
const allEmpty =
|
||||
s.completedThisCycle.size > 0 &&
|
||||
[...s.completedThisCycle].every((id) => s.emptyThisCycle.has(id));
|
||||
|
||||
// Reset cycle tracking
|
||||
s.emptyThisCycle = new Set();
|
||||
s.completedThisCycle = new Set();
|
||||
|
||||
if (allEmpty && !hasNewAgents) {
|
||||
// Enter dormant
|
||||
s.speakerList = newSpeakers;
|
||||
s.currentIndex = 0;
|
||||
s.dormant = true;
|
||||
return { next: null, enteredDormant: true };
|
||||
}
|
||||
|
||||
// Continue with updated list (apply shuffle if caller provides previousLastAgentId)
|
||||
s.speakerList = previousLastAgentId != null
|
||||
? shuffleList(newSpeakers, previousLastAgentId)
|
||||
: newSpeakers;
|
||||
s.currentIndex = 0;
|
||||
s.dormant = false;
|
||||
|
||||
return { next: s.speakerList[0] ?? null, enteredDormant: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the channel is waiting for a human reply.
|
||||
* Wake the channel from dormant.
|
||||
* Returns the new first speaker.
|
||||
*/
|
||||
export function isWaitingForHuman(channelId: string): boolean {
|
||||
const state = channelTurns.get(channelId);
|
||||
return !!state?.waitingForHuman;
|
||||
export function wakeFromDormant(channelId: string): SpeakerEntry | null {
|
||||
const s = getState(channelId);
|
||||
if (!s) return null;
|
||||
s.dormant = false;
|
||||
s.currentIndex = 0;
|
||||
s.emptyThisCycle = new Set();
|
||||
s.completedThisCycle = new Set();
|
||||
return s.speakerList[0] ?? null;
|
||||
}
|
||||
|
||||
export function isDormant(channelId: string): boolean {
|
||||
return getState(channelId)?.dormant ?? false;
|
||||
}
|
||||
|
||||
export function hasSpeakers(channelId: string): boolean {
|
||||
const s = getState(channelId);
|
||||
return (s?.speakerList.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the current speaker finishes (end symbol detected) or says NO_REPLY.
|
||||
* @param wasNoReply - true if the speaker said NO_REPLY (empty/silent)
|
||||
* @returns the new currentSpeaker (or null if dormant)
|
||||
* Shuffle a speaker list. Constraint: previousLastAgentId cannot be new first speaker.
|
||||
*/
|
||||
export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return null;
|
||||
if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore
|
||||
|
||||
if (wasNoReply) {
|
||||
state.noRepliedThisCycle.add(accountId);
|
||||
|
||||
// Check if ALL agents have NO_REPLY'd this cycle
|
||||
const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id));
|
||||
if (allNoReplied) {
|
||||
// If override active, restore original order before going dormant
|
||||
restoreOriginalOrder(state);
|
||||
// Go dormant
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Successful speech resets the cycle counter
|
||||
state.noRepliedThisCycle = new Set();
|
||||
export function shuffleList(list: SpeakerEntry[], previousLastAgentId?: string): SpeakerEntry[] {
|
||||
if (list.length <= 1) return list;
|
||||
const arr = [...list];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
|
||||
const prevSpeaker = state.currentSpeaker;
|
||||
const next = advanceTurn(channelId);
|
||||
|
||||
// Check if override cycle completed (returned to first agent)
|
||||
if (state.overrideFirstAgent && next === state.overrideFirstAgent) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return null; // go dormant after override cycle completes
|
||||
if (previousLastAgentId && arr[0].agentId === previousLastAgentId && arr.length > 1) {
|
||||
const swapIdx = 1 + Math.floor(Math.random() * (arr.length - 1));
|
||||
[arr[0], arr[swapIdx]] = [arr[swapIdx], arr[0]];
|
||||
}
|
||||
|
||||
// Check if we've completed a full cycle (all agents spoke once)
|
||||
// This happens when we're back to the first agent in the turn order
|
||||
const isFirstSpeakerAgain = next === state.turnOrder[0];
|
||||
if (!wasNoReply && !state.overrideFirstAgent && next && isFirstSpeakerAgain && state.noRepliedThisCycle.size === 0) {
|
||||
// Completed a full cycle without anyone NO_REPLYing - reshuffle if enabled
|
||||
const newOrder = reshuffleTurnOrder(channelId, state.turnOrder, prevSpeaker);
|
||||
if (newOrder !== state.turnOrder) {
|
||||
state.turnOrder = newOrder;
|
||||
markLastShuffled(channelId);
|
||||
console.log(`[dirigent][turn-debug] reshuffled turn order for channel=${channelId} newOrder=${JSON.stringify(newOrder)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to next speaker in order.
|
||||
*/
|
||||
export function advanceTurn(channelId: string): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return null;
|
||||
|
||||
if (state.currentSpeaker === null) return null;
|
||||
|
||||
const idx = state.turnOrder.indexOf(state.currentSpeaker);
|
||||
const nextIdx = (idx + 1) % state.turnOrder.length;
|
||||
|
||||
// Skip agents that already NO_REPLY'd this cycle
|
||||
let attempts = 0;
|
||||
let candidateIdx = nextIdx;
|
||||
while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) {
|
||||
candidateIdx = (candidateIdx + 1) % state.turnOrder.length;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (attempts >= state.turnOrder.length) {
|
||||
// All have NO_REPLY'd
|
||||
state.currentSpeaker = null;
|
||||
state.lastChangedAt = Date.now();
|
||||
return null;
|
||||
}
|
||||
|
||||
state.currentSpeaker = state.turnOrder[candidateIdx];
|
||||
state.lastChangedAt = Date.now();
|
||||
return state.currentSpeaker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset: go dormant.
|
||||
*/
|
||||
export function resetTurn(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (state) {
|
||||
restoreOriginalOrder(state);
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.waitingForHuman = false;
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug info.
|
||||
*/
|
||||
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state) return { channelId, hasTurnState: false };
|
||||
export function getDebugInfo(channelId: string) {
|
||||
const s = getState(channelId);
|
||||
if (!s) return { exists: false };
|
||||
return {
|
||||
channelId,
|
||||
hasTurnState: true,
|
||||
turnOrder: state.turnOrder,
|
||||
currentSpeaker: state.currentSpeaker,
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
waitingForHuman: state.waitingForHuman,
|
||||
hasOverride: !!state.savedTurnOrder,
|
||||
overrideFirstAgent: state.overrideFirstAgent || null,
|
||||
savedTurnOrder: state.savedTurnOrder || null,
|
||||
exists: true,
|
||||
speakerList: s.speakerList.map((sp) => sp.agentId),
|
||||
currentIndex: s.currentIndex,
|
||||
currentSpeaker: s.speakerList[s.currentIndex]?.agentId ?? null,
|
||||
dormant: s.dormant,
|
||||
emptyThisCycle: [...s.emptyThisCycle],
|
||||
completedThisCycle: [...s.completedThisCycle],
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a channel's turn state entirely (e.g. when archived). */
|
||||
export function clearChannel(channelId: string): void {
|
||||
channelStates().delete(channelId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user