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:
zhi
2026-02-27 23:27:36 +00:00
parent 1d8881577d
commit 476308d0df
4 changed files with 377 additions and 131 deletions

108
docs/TURN-WAKEUP-PROBLEM.md Normal file
View File

@@ -0,0 +1,108 @@
# Turn-Based Speaking: Wakeup Problem
## Context
WhisperGate implements turn-based speaking for Discord group channels where multiple AI agents coexist. Only one agent (the "current speaker") is allowed to respond at a time. Others are silenced via a no-reply model override.
## The Problem
When the current speaker responds with **NO_REPLY** (decides the message is not relevant to them), the turn advances to the next agent. However, **the next agent has no trigger to start speaking**.
### Why This Happens
1. A message arrives in the Discord channel
2. OpenClaw routes it to **all** agent sessions in that channel simultaneously
3. The WhisperGate plugin intercepts at `before_model_resolve`:
- Current speaker → allowed to process
- Everyone else → forced to no-reply model (message is "consumed" silently)
4. Current speaker processes the message and returns NO_REPLY
5. `message_sent` hook detects NO_REPLY → turn advances to next agent
6. **But the next agent already "consumed" the message in step 3** — their session processed it (as no-reply) and moved on
7. No new message exists to trigger the next agent
### The Result
After a NO_REPLY, the next speaker sits idle until a **new** message arrives in the channel (from a human or another source). The original message that should have been passed to the next speaker is lost.
## When This Matters
- **Single-round conversation**: Human asks a question → Agent A says NO_REPLY → Agent B should answer but can't
- **Chain conversations**: Agent A defers → Agent B defers → Agent C should speak but never gets triggered
## When This Doesn't Matter
- **End-symbol responses**: When an agent actually speaks (ends with 🔚), the turn advances and the next agent will respond to the **next** message. This is fine.
- **Human-driven channels**: If humans keep sending messages, the dormant state resolves quickly.
## Possible Solutions
### 1. Synthetic Trigger Message (Plugin-Side)
After detecting NO_REPLY and advancing the turn, the plugin sends a **synthetic message** to the channel that triggers the next agent.
**Challenges:**
- The plugin SDK (`message_sent` hook) doesn't have an API to inject messages into agent sessions
- Sending a real Discord message (even invisible like zero-width space) creates noise and may confuse other agents
- The synthetic message wouldn't contain the original user's context
### 2. Deferred Evaluation (Don't Block in before_model_resolve)
Instead of blocking non-speakers at `before_model_resolve`, let all agents receive the message but inject a "you are not the current speaker, reply NO_REPLY" instruction. The current speaker gets a normal prompt.
After the current speaker responds with NO_REPLY, the plugin would need to **re-trigger** the next agent's session with the same message.
**Challenges:**
- All agents still consume tokens for the NO_REPLY evaluation
- Re-triggering a session with an already-processed message requires OpenClaw internal APIs
### 3. Queue + Replay (Plugin-Side State)
The plugin stores the original message when it arrives. After NO_REPLY, it replays the message by injecting it into the next speaker's session.
**Challenges:**
- Requires access to session injection API (not available in current plugin SDK)
- Managing the message queue adds complexity
### 4. Gateway-Level Support (OpenClaw Core Change)
Add a plugin hook return value like `{ defer: true }` in `before_model_resolve` that tells OpenClaw: "don't process this message yet, but keep it pending." When the turn advances, the plugin could call `api.retrigger(sessionKey)` to replay the pending message.
**Challenges:**
- Requires changes to OpenClaw core, not just the plugin
- Needs design discussion with the OpenClaw team
### 5. Bot-to-Bot Handoff via Discord Message
When current speaker NO_REPLYs, have **that bot** send a brief handoff message in the channel: e.g., "(轮到下一位)" or a reaction. This real Discord message triggers all agents, and the turn manager ensures only the next speaker responds.
**Challenges:**
- Adds visible noise to the channel (could use a convention like a specific emoji reaction)
- The no-reply'd bot can't send messages (it was silenced)
- Could use the discord-control-api to send as a different bot
### 6. Timer-Based Retry (Pragmatic)
After advancing the turn, set a short timer (e.g., 2-3 seconds). If no new message has arrived, send a minimal trigger. This could be an internal "nudge" if the SDK supports it.
**Challenges:**
- Timing is fragile
- Still needs a mechanism to trigger the next agent
## Recommendation
**Solution 5 (Bot-to-Bot Handoff)** is the most pragmatic with current constraints. The implementation would be:
1. In the `message_sent` hook, after detecting NO_REPLY and advancing the turn:
2. Use the discord-control-api to send a short message (e.g., `[轮转]` or a specific emoji) from the **next speaker's bot account** in the channel
3. This real Discord message triggers OpenClaw to route it to all agents
4. The turn manager allows only the (now-current) next speaker to respond
5. The next speaker sees the original conversation context in their session history and responds appropriately
**Downside:** Adds a visible "[轮转]" message. Could be mitigated by immediately deleting it after delivery, or using a reaction instead of a message.
## Open Questions
1. Does the OpenClaw plugin SDK support injecting messages into sessions?
2. Can plugins access the Discord client to send messages directly?
3. Would an OpenClaw core `defer`/`retrigger` mechanism be feasible?
4. Is visible channel noise acceptable for the handoff message?

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
import { checkTurn, advanceTurn, resetTurn, setTurnPolicy, getTurnDebugInfo, type TurnPolicy } from "./turn-manager.js";
import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js";
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
@@ -158,7 +158,6 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
syncTurnPolicies();
} catch (err) {
api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
policyState.channelPolicies = {};
@@ -181,15 +180,63 @@ function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | und
return undefined;
}
/** Sync turn policies from channel policies into the turn manager */
function syncTurnPolicies(): void {
for (const [channelId, policy] of Object.entries(policyState.channelPolicies)) {
if (policy.turnOrder?.length) {
setTurnPolicy(channelId, { turnOrder: policy.turnOrder });
} else {
setTurnPolicy(channelId, undefined);
/**
* Get all Discord bot accountIds from config bindings (excluding humanList-bound agents).
*/
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return [];
const ids: string[] = [];
for (const b of bindings) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
ids.push(match.accountId);
}
}
return ids;
}
/**
* Ensure turn order is initialized for a channel.
* Uses all bot accounts from bindings as the turn order.
*/
function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void {
const botAccounts = getAllBotAccountIds(api);
if (botAccounts.length > 0) {
initTurnOrder(channelId, botAccounts);
}
}
/**
* Build agent identity string for injection into group chat prompts.
*/
function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
if (!Array.isArray(bindings)) return undefined;
// Find accountId for this agent
let accountId: string | undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
accountId = match.accountId;
break;
}
}
}
if (!accountId) return undefined;
// Find agent name
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
const name = (agent?.name as string) || agentId;
// Find Discord bot user ID from account token (not available directly)
// We'll use accountId as the identifier
return `你是 ${name}Discord 账号: ${accountId})。`;
}
function persistPolicies(api: OpenClawPluginApi): void {
@@ -200,7 +247,6 @@ function persistPolicies(api: OpenClawPluginApi): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(tmp, before, "utf8");
fs.renameSync(tmp, filePath);
syncTurnPolicies();
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
}
@@ -303,7 +349,6 @@ export default {
humanList: { type: "array", items: { type: "string" } },
agentList: { type: "array", items: { type: "string" } },
endSymbols: { type: "array", items: { type: "string" } },
turnOrder: { type: "array", items: { type: "string" } },
},
required: ["action"],
},
@@ -366,7 +411,6 @@ export default {
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined,
turnOrder: Array.isArray(params.turnOrder) ? (params.turnOrder as string[]) : undefined,
};
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
persistPolicies(api);
@@ -427,13 +471,17 @@ export default {
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
}
// Reset turn when human sends a message in a turn-managed channel
// Turn management on message received
if (preChannelId) {
ensureTurnOrder(api, preChannelId);
const from = typeof (e as Record<string, unknown>).from === "string" ? (e as Record<string, unknown>).from as string : "";
const humanList = livePre.humanList || livePre.bypassUserIds || [];
if (humanList.includes(from)) {
resetTurn(preChannelId);
api.logger.info(`whispergate: turn reset by human message in channel=${preChannelId} from=${from}`);
const isHuman = humanList.includes(from);
// Resolve sender's accountId (for bot messages)
const senderAccountId = typeof c.accountId === "string" ? c.accountId : undefined;
onNewMessage(preChannelId, senderAccountId, isHuman);
if (shouldDebugLog(livePre, preChannelId)) {
api.logger.info(`whispergate: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`);
}
}
} catch (err) {
@@ -490,10 +538,11 @@ export default {
// Turn-based check: if channel has turn order, only current speaker can respond
if (!rec.decision.shouldUseNoReply && derived.channelId) {
ensureTurnOrder(api, derived.channelId);
const accountId = resolveAccountId(api, ctx.agentId || "");
if (accountId) {
const turnCheck = checkTurn(derived.channelId, accountId);
if (!turnCheck.isSpeaker) {
if (!turnCheck.allowed) {
api.logger.info(
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
);
@@ -598,8 +647,15 @@ export default {
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
// Inject agent identity for group chats
let identity = "";
if (isGroupChat && ctx.agentId) {
const idStr = buildAgentIdentity(api, ctx.agentId);
if (idStr) identity = idStr + "\n\n";
}
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
return { prependContext: instruction };
return { prependContext: identity + instruction };
});
// Register slash commands for Discord
@@ -661,34 +717,21 @@ export default {
ensurePolicyStateLoaded(api, live);
const policy = resolvePolicy(live, channelId, policyState.channelPolicies);
// Check if this message ends the turn:
// 1. Content is empty (no-reply was used)
// 2. Content ends with an end symbol
// 3. Content is a gateway keyword like NO_REPLY
const trimmed = content.trim();
const isEmpty = trimmed.length === 0;
const isNoReply = /^NO_REPLY$/i.test(trimmed) || /^HEARTBEAT_OK$/i.test(trimmed);
const lastChar = trimmed.length > 0 ? Array.from(trimmed).pop() || "" : "";
const hasEndSymbol = !!lastChar && policy.endSymbols.includes(lastChar);
const wasNoReply = isEmpty || isNoReply;
if (isEmpty || isNoReply || hasEndSymbol) {
const nextSpeaker = advanceTurn(channelId);
if (nextSpeaker) {
api.logger.info(
`whispergate: turn advanced in channel=${channelId} from=${accountId} to=${nextSpeaker} ` +
`trigger=${isEmpty ? "empty" : isNoReply ? "no_reply" : "end_symbol"}`,
);
// Wake the next speaker by sending a nudge message to the channel
// The next agent will pick it up as a new message_received
// We use a zero-width space message to avoid visible noise
// Actually, we need the next agent's session to receive a trigger.
// The simplest approach: the turn manager just advances state.
// The next message in the channel (from any source) will allow
// the next speaker to respond. If the current speaker said NO_REPLY
// (empty content), the original message is still pending for the
// next speaker.
}
if (wasNoReply || hasEndSymbol) {
const nextSpeaker = onSpeakerDone(channelId, accountId, wasNoReply);
const trigger = wasNoReply ? (isEmpty ? "empty" : "no_reply_keyword") : "end_symbol";
api.logger.info(
`whispergate: turn onSpeakerDone channel=${channelId} from=${accountId} next=${nextSpeaker ?? "dormant"} trigger=${trigger}`,
);
// NOTE: if wasNoReply and nextSpeaker is set, the next agent needs
// a trigger message to start speaking. See TURN-WAKEUP-PROBLEM.md
}
} catch (err) {
api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`);

View File

@@ -17,8 +17,6 @@ export type ChannelPolicy = {
humanList?: string[];
agentList?: string[];
endSymbols?: string[];
/** Ordered list of Discord account IDs for turn-based speaking */
turnOrder?: string[];
};
export type Decision = {

View File

@@ -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,
};
}