/** * Turn-based speaking manager for group channels. * * Maintains per-channel turn order so that only one agent speaks at a time. * When the current speaker finishes (end symbol or NO_REPLY), the turn advances * to the next agent in the rotation. */ export type TurnPolicy = { /** Ordered list of Discord account IDs (bot user IDs) that participate in turn rotation */ turnOrder: string[]; }; export type ChannelTurnState = { /** Index into turnOrder for the current speaker */ currentIndex: number; /** Timestamp of last turn advance */ lastAdvancedAt: number; }; /** In-memory turn state per channel */ const channelTurns = new Map(); /** Turn policies per channel (loaded from channel policies) */ const turnPolicies = new Map(); /** Turn timeout: if the current speaker hasn't responded in this time, auto-advance */ const TURN_TIMEOUT_MS = 30_000; export function setTurnPolicy(channelId: string, policy: TurnPolicy | undefined): void { if (!policy || !policy.turnOrder?.length) { turnPolicies.delete(channelId); channelTurns.delete(channelId); return; } turnPolicies.set(channelId, policy); // Initialize turn state if not exists if (!channelTurns.has(channelId)) { channelTurns.set(channelId, { currentIndex: 0, lastAdvancedAt: Date.now() }); } } export function getTurnPolicy(channelId: string): TurnPolicy | undefined { return turnPolicies.get(channelId); } export function getTurnState(channelId: string): ChannelTurnState | undefined { return channelTurns.get(channelId); } /** * Check if the given accountId is the current speaker for this channel. * Returns: { isSpeaker: true } or { isSpeaker: false, reason: string } */ export function checkTurn(channelId: string, accountId: string): { isSpeaker: boolean; currentSpeaker?: string; reason: string } { const policy = turnPolicies.get(channelId); if (!policy) { return { isSpeaker: true, reason: "no_turn_policy" }; } const order = policy.turnOrder; if (!order.includes(accountId)) { // Not in turn order — could be human or unmanaged agent, allow through return { isSpeaker: true, reason: "not_in_turn_order" }; } let state = channelTurns.get(channelId); if (!state) { state = { currentIndex: 0, lastAdvancedAt: Date.now() }; channelTurns.set(channelId, state); } // Auto-advance if turn has timed out const now = Date.now(); if (now - state.lastAdvancedAt > TURN_TIMEOUT_MS) { advanceTurn(channelId); state = channelTurns.get(channelId)!; } const currentSpeaker = order[state.currentIndex]; if (accountId === currentSpeaker) { return { isSpeaker: true, currentSpeaker, reason: "is_current_speaker" }; } return { isSpeaker: false, currentSpeaker, reason: "not_current_speaker" }; } /** * Advance turn to the next agent in rotation. * Returns the new current speaker's accountId. */ export function advanceTurn(channelId: string): string | undefined { const policy = turnPolicies.get(channelId); if (!policy) return undefined; const order = policy.turnOrder; let state = channelTurns.get(channelId); if (!state) { state = { currentIndex: 0, lastAdvancedAt: Date.now() }; channelTurns.set(channelId, state); return order[0]; } state.currentIndex = (state.currentIndex + 1) % order.length; state.lastAdvancedAt = Date.now(); return order[state.currentIndex]; } /** * Reset turn to the first agent (e.g., when a human sends a message). */ export function resetTurn(channelId: string): void { const state = channelTurns.get(channelId); if (state) { state.currentIndex = 0; state.lastAdvancedAt = Date.now(); } } /** * Get debug info for a channel's turn state. */ export function getTurnDebugInfo(channelId: string): Record { const policy = turnPolicies.get(channelId); const state = channelTurns.get(channelId); if (!policy) return { channelId, hasTurnPolicy: false }; return { channelId, hasTurnPolicy: true, turnOrder: policy.turnOrder, currentIndex: state?.currentIndex ?? 0, currentSpeaker: policy.turnOrder[state?.currentIndex ?? 0], lastAdvancedAt: state?.lastAdvancedAt, timeSinceAdvanceMs: state ? Date.now() - state.lastAdvancedAt : null, }; }