refactor: auto-managed turn order + dormant state + identity injection
Turn system redesign:
- Turn order auto-populated from config bindings (all bot accounts)
- No manual turnOrder config needed
- Humans (humanList) excluded from turn order automatically
- Dormant state: when all agents NO_REPLY in a cycle, currentSpeaker=null
- Reactivation: any new message wakes the system
- Human message → start from first in order
- Bot not in order → start from first
- Bot in order → next after sender
- Skip already-NO_REPLY'd agents when advancing
Identity injection:
- Group chat prompts now include agent identity
- Format: '你是 {name}(Discord 账号: {accountId})'
Other:
- Remove turnOrder from ChannelPolicy (no longer configurable)
- Add TURN-WAKEUP-PROBLEM.md documenting the NO_REPLY wake-up challenge
- Update message_received to call onNewMessage with proper human detection
- Update message_sent to call onSpeakerDone with NO_REPLY tracking
This commit is contained in:
@@ -1,136 +1,233 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
|
||||
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;
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** In-memory turn state per channel */
|
||||
const channelTurns = new Map<string, ChannelTurnState>();
|
||||
|
||||
/** Turn policies per channel (loaded from channel policies) */
|
||||
const turnPolicies = new Map<string, TurnPolicy>();
|
||||
/** Turn timeout: if the current speaker hasn't responded, auto-advance */
|
||||
const TURN_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Turn timeout: if the current speaker hasn't responded in this time, auto-advance */
|
||||
const TURN_TIMEOUT_MS = 30_000;
|
||||
// --- helpers ---
|
||||
|
||||
export function setTurnPolicy(channelId: string, policy: TurnPolicy | undefined): void {
|
||||
if (!policy || !policy.turnOrder?.length) {
|
||||
turnPolicies.delete(channelId);
|
||||
channelTurns.delete(channelId);
|
||||
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 ---
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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; // no change
|
||||
}
|
||||
|
||||
channelTurns.set(channelId, {
|
||||
turnOrder: shuffleArray(botAccountIds),
|
||||
currentSpeaker: null, // start dormant
|
||||
noRepliedThisCycle: new Set(),
|
||||
lastChangedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
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.
|
||||
*
|
||||
* @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 (isHuman) {
|
||||
// Human message: activate, start from first in order
|
||||
state.currentSpeaker = state.turnOrder[0];
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
turnPolicies.set(channelId, policy);
|
||||
// Initialize turn state if not exists
|
||||
if (!channelTurns.has(channelId)) {
|
||||
channelTurns.set(channelId, { currentIndex: 0, lastAdvancedAt: Date.now() });
|
||||
|
||||
if (state.currentSpeaker !== null) {
|
||||
// Already active, no change needed from incoming message
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTurnPolicy(channelId: string): TurnPolicy | undefined {
|
||||
return turnPolicies.get(channelId);
|
||||
}
|
||||
|
||||
export function getTurnState(channelId: string): ChannelTurnState | undefined {
|
||||
return channelTurns.get(channelId);
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given accountId is the current speaker for this channel.
|
||||
* Returns: { isSpeaker: true } or { isSpeaker: false, reason: string }
|
||||
* 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 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" };
|
||||
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();
|
||||
}
|
||||
|
||||
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" };
|
||||
return advanceTurn(channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance turn to the next agent in rotation.
|
||||
* Returns the new current speaker's accountId.
|
||||
* Advance to next speaker in order.
|
||||
*/
|
||||
export function advanceTurn(channelId: string): string | undefined {
|
||||
const policy = turnPolicies.get(channelId);
|
||||
if (!policy) return undefined;
|
||||
export function advanceTurn(channelId: string): string | null {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!state || state.turnOrder.length === 0) return null;
|
||||
|
||||
const order = policy.turnOrder;
|
||||
let state = channelTurns.get(channelId);
|
||||
if (!state) {
|
||||
state = { currentIndex: 0, lastAdvancedAt: Date.now() };
|
||||
channelTurns.set(channelId, state);
|
||||
return order[0];
|
||||
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++;
|
||||
}
|
||||
|
||||
state.currentIndex = (state.currentIndex + 1) % order.length;
|
||||
state.lastAdvancedAt = Date.now();
|
||||
return order[state.currentIndex];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset turn to the first agent (e.g., when a human sends a message).
|
||||
* Force reset: go dormant.
|
||||
*/
|
||||
export function resetTurn(channelId: string): void {
|
||||
const state = channelTurns.get(channelId);
|
||||
if (state) {
|
||||
state.currentIndex = 0;
|
||||
state.lastAdvancedAt = Date.now();
|
||||
state.currentSpeaker = null;
|
||||
state.noRepliedThisCycle = new Set();
|
||||
state.lastChangedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug info for a channel's turn state.
|
||||
* Get debug info.
|
||||
*/
|
||||
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
|
||||
const policy = turnPolicies.get(channelId);
|
||||
const state = channelTurns.get(channelId);
|
||||
if (!policy) return { channelId, hasTurnPolicy: false };
|
||||
if (!state) return { channelId, hasTurnState: 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,
|
||||
hasTurnState: true,
|
||||
turnOrder: state.turnOrder,
|
||||
currentSpeaker: state.currentSpeaker,
|
||||
noRepliedThisCycle: [...state.noRepliedThisCycle],
|
||||
lastChangedAt: state.lastChangedAt,
|
||||
dormant: state.currentSpeaker === null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user