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 - 重置轮流顺序
This commit is contained in:
191
plugin/index.ts
191
plugin/index.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
|
import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js";
|
||||||
|
import { checkTurn, advanceTurn, resetTurn, setTurnPolicy, getTurnDebugInfo, type TurnPolicy } from "./turn-manager.js";
|
||||||
|
|
||||||
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list";
|
||||||
|
|
||||||
@@ -24,9 +25,13 @@ type DebugConfig = {
|
|||||||
const sessionDecision = new Map<string, DecisionRecord>();
|
const sessionDecision = new Map<string, DecisionRecord>();
|
||||||
const MAX_SESSION_DECISIONS = 2000;
|
const MAX_SESSION_DECISIONS = 2000;
|
||||||
const DECISION_TTL_MS = 5 * 60 * 1000;
|
const DECISION_TTL_MS = 5 * 60 * 1000;
|
||||||
function buildEndMarkerInstruction(endSymbols: string[]): string {
|
function buildEndMarkerInstruction(endSymbols: string[], isGroupChat: boolean): string {
|
||||||
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚";
|
||||||
return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`;
|
let instruction = `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`;
|
||||||
|
if (isGroupChat) {
|
||||||
|
instruction += `\n\n群聊发言规则:如果这条消息与你无关、不需要你回应、或你没有有价值的补充,请主动回复 NO_REPLY。不要为了说话而说话。`;
|
||||||
|
}
|
||||||
|
return instruction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const policyState: PolicyState = {
|
const policyState: PolicyState = {
|
||||||
@@ -153,12 +158,40 @@ function ensurePolicyStateLoaded(api: OpenClawPluginApi, config: WhisperGateConf
|
|||||||
const raw = fs.readFileSync(filePath, "utf8");
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
const parsed = JSON.parse(raw) as Record<string, ChannelPolicy>;
|
||||||
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
policyState.channelPolicies = parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
syncTurnPolicies();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
|
api.logger.warn(`whispergate: failed init policy file ${filePath}: ${String(err)}`);
|
||||||
policyState.channelPolicies = {};
|
policyState.channelPolicies = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve agentId → Discord accountId from config bindings */
|
||||||
|
function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
|
||||||
|
const root = (api.config as Record<string, unknown>) || {};
|
||||||
|
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (!Array.isArray(bindings)) return 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") {
|
||||||
|
return match.accountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function persistPolicies(api: OpenClawPluginApi): void {
|
function persistPolicies(api: OpenClawPluginApi): void {
|
||||||
const filePath = policyState.filePath;
|
const filePath = policyState.filePath;
|
||||||
if (!filePath) throw new Error("policy file path not initialized");
|
if (!filePath) throw new Error("policy file path not initialized");
|
||||||
@@ -167,6 +200,7 @@ function persistPolicies(api: OpenClawPluginApi): void {
|
|||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
fs.writeFileSync(tmp, before, "utf8");
|
fs.writeFileSync(tmp, before, "utf8");
|
||||||
fs.renameSync(tmp, filePath);
|
fs.renameSync(tmp, filePath);
|
||||||
|
syncTurnPolicies();
|
||||||
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
|
api.logger.info(`whispergate: policy file persisted: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +268,7 @@ export default {
|
|||||||
|
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
{
|
{
|
||||||
name: "whispergateway_tools",
|
name: "whispergate_tools",
|
||||||
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
description: "WhisperGate unified tool: Discord admin actions + in-memory policy management.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -242,7 +276,7 @@ export default {
|
|||||||
properties: {
|
properties: {
|
||||||
action: {
|
action: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel"],
|
enum: ["channel-private-create", "channel-private-update", "member-list", "policy-get", "policy-set-channel", "policy-delete-channel", "turn-status", "turn-advance", "turn-reset"],
|
||||||
},
|
},
|
||||||
guildId: { type: "string" },
|
guildId: { type: "string" },
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
@@ -269,6 +303,7 @@ export default {
|
|||||||
humanList: { type: "array", items: { type: "string" } },
|
humanList: { type: "array", items: { type: "string" } },
|
||||||
agentList: { type: "array", items: { type: "string" } },
|
agentList: { type: "array", items: { type: "string" } },
|
||||||
endSymbols: { type: "array", items: { type: "string" } },
|
endSymbols: { type: "array", items: { type: "string" } },
|
||||||
|
turnOrder: { type: "array", items: { type: "string" } },
|
||||||
},
|
},
|
||||||
required: ["action"],
|
required: ["action"],
|
||||||
},
|
},
|
||||||
@@ -303,7 +338,7 @@ export default {
|
|||||||
const text = await r.text();
|
const text = await r.text();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `whispergateway_tools discord failed (${r.status}): ${text}` }],
|
content: [{ type: "text", text: `whispergate_tools discord failed (${r.status}): ${text}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -331,6 +366,7 @@ export default {
|
|||||||
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
|
humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined,
|
||||||
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
|
agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined,
|
||||||
endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols 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;
|
policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record<string, unknown>) as ChannelPolicy;
|
||||||
persistPolicies(api);
|
persistPolicies(api);
|
||||||
@@ -355,6 +391,26 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "turn-status") {
|
||||||
|
const channelId = String(params.channelId || "").trim();
|
||||||
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "turn-advance") {
|
||||||
|
const channelId = String(params.channelId || "").trim();
|
||||||
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
|
const next = advanceTurn(channelId);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, nextSpeaker: next, ...getTurnDebugInfo(channelId) }) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "turn-reset") {
|
||||||
|
const channelId = String(params.channelId || "").trim();
|
||||||
|
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
|
||||||
|
resetTurn(channelId);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, ...getTurnDebugInfo(channelId) }) }] };
|
||||||
|
}
|
||||||
|
|
||||||
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -370,6 +426,16 @@ export default {
|
|||||||
if (shouldDebugLog(livePre, preChannelId)) {
|
if (shouldDebugLog(livePre, preChannelId)) {
|
||||||
api.logger.info(`whispergate: debug message_received preflight ctx=${JSON.stringify(debugCtxSummary(c, e))}`);
|
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
|
||||||
|
if (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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
api.logger.warn(`whispergate: message hook failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
@@ -422,6 +488,23 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Turn-based check: if channel has turn order, only current speaker can respond
|
||||||
|
if (!rec.decision.shouldUseNoReply && derived.channelId) {
|
||||||
|
const accountId = resolveAccountId(api, ctx.agentId || "");
|
||||||
|
if (accountId) {
|
||||||
|
const turnCheck = checkTurn(derived.channelId, accountId);
|
||||||
|
if (!turnCheck.isSpeaker) {
|
||||||
|
api.logger.info(
|
||||||
|
`whispergate: turn gate blocked session=${key} accountId=${accountId} currentSpeaker=${turnCheck.currentSpeaker} reason=${turnCheck.reason}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
providerOverride: live.noReplyProvider,
|
||||||
|
modelOverride: live.noReplyModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!rec.decision.shouldUseNoReply) {
|
if (!rec.decision.shouldUseNoReply) {
|
||||||
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
// 如果之前有 no-reply 执行过,现在不需要了,清除 override 恢复原模型
|
||||||
if (rec.needsRestore) {
|
if (rec.needsRestore) {
|
||||||
@@ -512,10 +595,104 @@ export default {
|
|||||||
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
const prompt = ((event as Record<string, unknown>).prompt as string) || "";
|
||||||
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider);
|
||||||
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies);
|
||||||
const instruction = buildEndMarkerInstruction(policy.endSymbols);
|
const isGroupChat = derived.conv.is_group_chat === true || derived.conv.is_group_chat === "true";
|
||||||
|
const instruction = buildEndMarkerInstruction(policy.endSymbols, isGroupChat);
|
||||||
|
|
||||||
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`);
|
api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason} isGroupChat=${isGroupChat}`);
|
||||||
return { prependContext: instruction };
|
return { prependContext: instruction };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register slash commands for Discord
|
||||||
|
api.registerCommand({
|
||||||
|
name: "whispergate",
|
||||||
|
description: "WhisperGate 频道策略管理",
|
||||||
|
acceptsArgs: true,
|
||||||
|
handler: async (cmdCtx) => {
|
||||||
|
const args = cmdCtx.args || "";
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
const subCmd = parts[0] || "help";
|
||||||
|
|
||||||
|
if (subCmd === "help") {
|
||||||
|
return { text: `WhisperGate 命令:\n` +
|
||||||
|
`/whispergate status - 显示当前频道状态\n` +
|
||||||
|
`/whispergate turn-status - 显示轮流发言状态\n` +
|
||||||
|
`/whispergate turn-advance - 手动推进轮流\n` +
|
||||||
|
`/whispergate turn-reset - 重置轮流顺序` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subCmd === "status") {
|
||||||
|
return { text: JSON.stringify({ policies: policyState.channelPolicies }, null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subCmd === "turn-status") {
|
||||||
|
const channelId = cmdCtx.channelId;
|
||||||
|
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||||
|
return { text: JSON.stringify(getTurnDebugInfo(channelId), null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subCmd === "turn-advance") {
|
||||||
|
const channelId = cmdCtx.channelId;
|
||||||
|
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||||
|
const next = advanceTurn(channelId);
|
||||||
|
return { text: JSON.stringify({ ok: true, nextSpeaker: next }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subCmd === "turn-reset") {
|
||||||
|
const channelId = cmdCtx.channelId;
|
||||||
|
if (!channelId) return { text: "无法获取频道ID", isError: true };
|
||||||
|
resetTurn(channelId);
|
||||||
|
return { text: JSON.stringify({ ok: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: `未知子命令: ${subCmd}`, isError: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Turn advance: when an agent sends a message, check if it signals end of turn
|
||||||
|
api.on("message_sent", async (event, ctx) => {
|
||||||
|
try {
|
||||||
|
const channelId = ctx.channelId;
|
||||||
|
const accountId = ctx.accountId;
|
||||||
|
const content = event.content || "";
|
||||||
|
|
||||||
|
if (!channelId || !accountId) return;
|
||||||
|
|
||||||
|
const live = getLivePluginConfig(api, baseConfig as WhisperGateConfig) as WhisperGateConfig & DebugConfig;
|
||||||
|
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);
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
api.logger.warn(`whispergate: message_sent hook failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type ChannelPolicy = {
|
|||||||
humanList?: string[];
|
humanList?: string[];
|
||||||
agentList?: string[];
|
agentList?: string[];
|
||||||
endSymbols?: string[];
|
endSymbols?: string[];
|
||||||
|
/** Ordered list of Discord account IDs for turn-based speaking */
|
||||||
|
turnOrder?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Decision = {
|
export type Decision = {
|
||||||
|
|||||||
136
plugin/turn-manager.ts
Normal file
136
plugin/turn-manager.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user