Files
Dirigent/plugin/turn-manager.ts

456 lines
16 KiB
TypeScript

/**
* Turn-based speaking manager for group channels.
*
* 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
*/
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;
};
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 ---
/**
* Initialize or update the turn order for a channel.
* Called with the list of bot accountIds (already filtered, humans excluded).
*/
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
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)}`,
);
const nextOrder = shuffleArray(botAccountIds);
// 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`,
);
}
/**
* Check if the given accountId is allowed to speak.
*/
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" };
}
// Waiting for human → block all agents
if (state.waitingForHuman) {
return { allowed: false, currentSpeaker: null, reason: "waiting_for_human" };
}
// 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" };
}
// 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" };
}
/**
* 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
*/
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();
}
/**
* 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)}`,
);
return true;
}
/**
* Check if a mention override is currently active.
*/
export function hasMentionOverride(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.savedTurnOrder;
}
/**
* Set the channel to "waiting for human" state.
* All agents will be routed to no-reply until a human sends a message.
*/
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();
}
/**
* Check if the channel is waiting for a human reply.
*/
export function isWaitingForHuman(channelId: string): boolean {
const state = channelTurns.get(channelId);
return !!state?.waitingForHuman;
}
/**
* 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)
*/
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();
}
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
}
// 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;
}
/**
* 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 };
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,
};
}