Files
Dirigent/plugin/turn-manager.ts
zhi 8e3f8e7c4e feat: improve turn order initialization and member management
1. Turn order now activates via moderator on first creation (discovery-based),
   ensuring no agent consumes messages before the turn list is ready.
   All agents are blocked (dormant) until moderator sends handoff to first speaker.

2. channel-private-create: pre-populates turn order from allowedUserIds,
   filtering to only bot accounts (excluding humans and moderator).
   Immediately activates first speaker via moderator handoff.

3. channel-private-update: updates turn order when members are added/removed.
   If current speaker is removed, activates next available speaker.

4. Any member (human or bot) can now trigger turn activation when dormant,
   not just humans. Human messages still reset the cycle.

5. Added resolveAccountIdByUserId helper to map Discord user IDs back to
   account names from bot tokens.

6. turn-manager: added initTurnOrderPrePopulated, updateTurnMembers,
   activateFirstSpeaker, hasTurnState exports.
2026-03-02 00:55:55 +00:00

364 lines
12 KiB
TypeScript

/**
* Turn-based speaking manager for group channels.
*
* Rules:
* - Humans (humanList) and the moderator bot are never in the turn order
* - Turn order is auto-populated from channel/server members minus humans/moderator
* - 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
* - When turn order is first created via discovery, all agents are forced no-reply
* until moderator activates the first speaker
* - When turn order is pre-populated (e.g. channel-private-create), the first
* speaker is immediately activated via moderator handoff
*/
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;
/**
* Whether this turn state was pre-populated (e.g. from channel-private-create params).
* Pre-populated states have a complete member list and can be activated immediately.
* Discovery-based states may be incomplete and need stabilization.
*/
prePopulated: 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;
}
// --- public API ---
/**
* Check if a channel already has turn state initialized.
*/
export function hasTurnState(channelId: string): boolean {
return channelTurns.has(channelId);
}
/**
* Initialize or update the turn order for a channel.
* Called with the list of bot accountIds (already filtered, humans excluded).
* Starts in dormant state — activation happens via onNewMessage or activateFirstSpeaker.
*
* @returns true if a NEW turn state was created (callers should trigger moderator activation)
*/
export function initTurnOrder(channelId: string, botAccountIds: string[]): boolean {
const existing = channelTurns.get(channelId);
if (existing) {
// Check if membership changed
const oldSet = new Set(existing.turnOrder);
const newSet = new Set(botAccountIds);
const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id));
if (same) return false; // no change
// Membership changed — update turn order, preserve current state where possible
const newOrder = shuffleArray(botAccountIds);
existing.turnOrder = newOrder;
// If current speaker was removed, go dormant
if (existing.currentSpeaker && !newSet.has(existing.currentSpeaker)) {
existing.currentSpeaker = newOrder.length > 0 ? newOrder[0] : null;
}
// Clean up noRepliedThisCycle
for (const id of existing.noRepliedThisCycle) {
if (!newSet.has(id)) existing.noRepliedThisCycle.delete(id);
}
existing.lastChangedAt = Date.now();
return false;
}
channelTurns.set(channelId, {
turnOrder: shuffleArray(botAccountIds),
currentSpeaker: null, // start dormant
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
prePopulated: false,
});
return true;
}
/**
* Initialize turn order from a known complete member list (e.g. channel-private-create).
* Immediately activates the first speaker.
*
* @returns the first speaker accountId, or null if no bots
*/
export function initTurnOrderPrePopulated(channelId: string, botAccountIds: string[]): string | null {
if (botAccountIds.length === 0) return null;
const order = shuffleArray(botAccountIds);
channelTurns.set(channelId, {
turnOrder: order,
currentSpeaker: order[0],
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
prePopulated: true,
});
return order[0];
}
/**
* Update turn order when members are added or removed (e.g. channel-private-update).
* Preserves current speaker if still present, otherwise activates first in new order.
*
* @returns { turnOrder, currentSpeaker, needsHandoff } — needsHandoff is true if
* currentSpeaker changed and moderator should send a handoff message
*/
export function updateTurnMembers(
channelId: string,
addAccountIds: string[],
removeAccountIds: string[],
): { turnOrder: string[]; currentSpeaker: string | null; needsHandoff: boolean } {
const state = channelTurns.get(channelId);
const removeSet = new Set(removeAccountIds);
if (!state) {
// No existing state — create fresh with the added members
const order = shuffleArray(addAccountIds.filter(id => !removeSet.has(id)));
if (order.length === 0) return { turnOrder: [], currentSpeaker: null, needsHandoff: false };
channelTurns.set(channelId, {
turnOrder: order,
currentSpeaker: order[0],
noRepliedThisCycle: new Set(),
lastChangedAt: Date.now(),
prePopulated: true,
});
return { turnOrder: order, currentSpeaker: order[0], needsHandoff: true };
}
const oldSpeaker = state.currentSpeaker;
// Remove members
if (removeAccountIds.length > 0) {
state.turnOrder = state.turnOrder.filter(id => !removeSet.has(id));
for (const id of removeAccountIds) {
state.noRepliedThisCycle.delete(id);
}
}
// Add new members (append, don't shuffle existing order to preserve continuity)
for (const id of addAccountIds) {
if (!removeSet.has(id) && !state.turnOrder.includes(id)) {
state.turnOrder.push(id);
}
}
// Handle current speaker removal
let needsHandoff = false;
if (oldSpeaker && !state.turnOrder.includes(oldSpeaker)) {
// Current speaker was removed — activate first available
state.currentSpeaker = state.turnOrder.length > 0 ? state.turnOrder[0] : null;
needsHandoff = state.currentSpeaker !== null;
}
state.lastChangedAt = Date.now();
return { turnOrder: state.turnOrder, currentSpeaker: state.currentSpeaker, needsHandoff };
}
/**
* Activate the first speaker in the turn order.
* Used after turn order creation to start the cycle.
*
* @returns the activated speaker accountId, or null if no bots / already active
*/
export function activateFirstSpeaker(channelId: string): string | null {
const state = channelTurns.get(channelId);
if (!state || state.turnOrder.length === 0) return null;
if (state.currentSpeaker !== null) return null; // already active
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
return state.currentSpeaker;
}
/**
* 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" };
}
// 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 or moderator)
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.
* Any member (human or bot) can trigger activation when dormant.
*
* @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;
if (state.currentSpeaker !== null) {
// Already active — human messages reset the cycle, bot messages do nothing
if (isHuman) {
state.currentSpeaker = state.turnOrder[0];
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
}
return;
}
// Dormant state → reactivate (any member can trigger)
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 (human or unknown) → start from first
state.currentSpeaker = state.turnOrder[0];
}
state.noRepliedThisCycle = new Set();
state.lastChangedAt = Date.now();
}
/**
* 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) {
// 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();
}
return advanceTurn(channelId);
}
/**
* 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) {
state.currentSpeaker = null;
state.noRepliedThisCycle = new Set();
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,
prePopulated: state.prePopulated,
};
}