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
234 lines
7.5 KiB
TypeScript
234 lines
7.5 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
|
|
*/
|
|
|
|
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;
|
|
};
|
|
|
|
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 ---
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|