456 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|