diff --git a/docs/TURN-WAKEUP-PROBLEM.md b/docs/TURN-WAKEUP-PROBLEM.md new file mode 100644 index 0000000..f56b59f --- /dev/null +++ b/docs/TURN-WAKEUP-PROBLEM.md @@ -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? diff --git a/plugin/index.ts b/plugin/index.ts index 53e0adb..b969599 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -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; 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) || {}; + const bindings = root.bindings as Array> | undefined; + if (!Array.isArray(bindings)) return []; + const ids: string[] = []; + for (const b of bindings) { + const match = b.match as Record | 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) || {}; + const bindings = root.bindings as Array> | undefined; + const agents = ((root.agents as Record)?.list as Array>) || []; + 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 | 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) => 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) 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).from === "string" ? (e as Record).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)}`); diff --git a/plugin/rules.ts b/plugin/rules.ts index 4152a80..ca00593 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -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 = { diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 7bb8403..033699e 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -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; + /** Timestamp of last state change */ + lastChangedAt: number; }; -/** In-memory turn state per channel */ const channelTurns = new Map(); -/** Turn policies per channel (loaded from channel policies) */ -const turnPolicies = new Map(); +/** 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(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 { - 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, }; }