Files
Dirigent/plugin/turn-manager.ts
zhi 9b5316738b feat: turn-based speaking + slash commands + enhanced prompts
1. Rename tool: whispergateway_tools → whispergate_tools

2. Turn-based speaking mechanism:
   - New turn-manager.ts maintains per-channel turn state
   - ChannelPolicy新增turnOrder字段配置发言顺序
   - before_model_resolve hook检查当前agent是否为发言人
   - 非当前发言人直接切换到no-reply模型
   - message_sent hook检测结束符或NO_REPLY时推进turn
   - message_received检测到human消息时重置turn

3. 注入提示词增强:
   - buildEndMarkerInstruction增加isGroupChat参数
   - 群聊时追加规则:与自己无关时主动回复NO_REPLY

4. Slash command支持:
   - /whispergate status - 查看频道策略
   - /whispergate turn-status - 查看轮流状态
   - /whispergate turn-advance - 手动推进轮流
   - /whispergate turn-reset - 重置轮流顺序
2026-02-27 16:05:39 +00:00

137 lines
4.2 KiB
TypeScript

/**
* 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.
*/
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;
};
/** 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 in this time, auto-advance */
const TURN_TIMEOUT_MS = 30_000;
export function setTurnPolicy(channelId: string, policy: TurnPolicy | undefined): void {
if (!policy || !policy.turnOrder?.length) {
turnPolicies.delete(channelId);
channelTurns.delete(channelId);
return;
}
turnPolicies.set(channelId, policy);
// Initialize turn state if not exists
if (!channelTurns.has(channelId)) {
channelTurns.set(channelId, { currentIndex: 0, lastAdvancedAt: Date.now() });
}
}
export function getTurnPolicy(channelId: string): TurnPolicy | undefined {
return turnPolicies.get(channelId);
}
export function getTurnState(channelId: string): ChannelTurnState | undefined {
return channelTurns.get(channelId);
}
/**
* Check if the given accountId is the current speaker for this channel.
* Returns: { isSpeaker: true } or { isSpeaker: false, reason: string }
*/
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" };
}
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" };
}
/**
* Advance turn to the next agent in rotation.
* Returns the new current speaker's accountId.
*/
export function advanceTurn(channelId: string): string | undefined {
const policy = turnPolicies.get(channelId);
if (!policy) return undefined;
const order = policy.turnOrder;
let state = channelTurns.get(channelId);
if (!state) {
state = { currentIndex: 0, lastAdvancedAt: Date.now() };
channelTurns.set(channelId, state);
return order[0];
}
state.currentIndex = (state.currentIndex + 1) % order.length;
state.lastAdvancedAt = Date.now();
return order[state.currentIndex];
}
/**
* Reset turn to the first agent (e.g., when a human sends a message).
*/
export function resetTurn(channelId: string): void {
const state = channelTurns.get(channelId);
if (state) {
state.currentIndex = 0;
state.lastAdvancedAt = Date.now();
}
}
/**
* Get debug info for a channel's turn state.
*/
export function getTurnDebugInfo(channelId: string): Record<string, unknown> {
const policy = turnPolicies.get(channelId);
const state = channelTurns.get(channelId);
if (!policy) return { channelId, hasTurnPolicy: 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,
};
}