From bfbe40b3c64024354fe4c7d4ff58acb2f5ae62fa Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 2 Apr 2026 04:36:36 +0000 Subject: [PATCH] feat: implement multi-message mode and shuffle mode features - Add multi-message mode with start/end/prompt markers - Implement turn order shuffling with /turn-shuffling command - Add channel mode state management - Update hooks to handle multi-message mode behavior - Update plugin config with new markers - Update TASKLIST.md with completed tasks --- plans/TASKLIST.md | 150 +++++++++++++-------------- plugin/commands/dirigent-command.ts | 21 ++++ plugin/core/channel-modes.ts | 51 +++++++++ plugin/hooks/before-message-write.ts | 23 ++-- plugin/hooks/before-model-resolve.ts | 11 ++ plugin/hooks/message-received.ts | 46 +++++--- plugin/openclaw.plugin.json | 5 +- plugin/turn-manager.ts | 42 ++++++++ 8 files changed, 251 insertions(+), 98 deletions(-) create mode 100644 plugin/core/channel-modes.ts diff --git a/plans/TASKLIST.md b/plans/TASKLIST.md index eb8371a..37ddbf5 100644 --- a/plans/TASKLIST.md +++ b/plans/TASKLIST.md @@ -22,53 +22,53 @@ ### A3. `plugin/tools/register-tools.ts` #### A3.1 扩展 `discord_channel_create` -- [ ] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑 -- [ ] 为 `discord_channel_create` 增加可选参数 `callbackChannelId` -- [ ] 为 `discord_channel_create` 增加可选参数 `discussGuide` -- [ ] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide` -- [ ] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致 -- [ ] 创建成功后识别是否为 discussion 模式 channel -- [ ] 在 discussion 模式下调用 metadata 初始化逻辑 -- [ ] 在 discussion 模式下调用 moderator kickoff 发送逻辑 +- [x] 阅读当前 `discord_channel_create` 的参数定义与执行逻辑 +- [x] 为 `discord_channel_create` 增加可选参数 `callbackChannelId` +- [x] 为 `discord_channel_create` 增加可选参数 `discussGuide` +- [x] 添加联动校验:若传 `callbackChannelId`,则必须传 `discussGuide` +- [x] 保证不传 `callbackChannelId` 时,行为与当前版本完全一致 +- [x] 创建成功后识别是否为 discussion 模式 channel +- [x] 在 discussion 模式下调用 metadata 初始化逻辑 +- [x] 在 discussion 模式下调用 moderator kickoff 发送逻辑 #### A3.2 注册 `discuss-callback` -- [ ] 定义 `discuss-callback` 的 parameters schema -- [ ] 注册新工具 `discuss-callback` -- [ ] 将 `discuss-callback` 执行逻辑接到 discussion service / manager -- [ ] 为工具失败场景返回可读错误信息 +- [x] 定义 `discuss-callback` 的 parameters schema +- [x] 注册新工具 `discuss-callback` +- [x] 将 `discuss-callback` 执行逻辑接到 discussion service / manager +- [x] 为工具失败场景返回可读错误信息 ### A4. `plugin/core/` 新增 discussion metadata/service 模块 #### A4.1 新建 metadata/state 模块 -- [ ] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`) -- [ ] 定义 discussion metadata 类型: - - [ ] `mode` - - [ ] `discussionChannelId` - - [ ] `originChannelId` - - [ ] `initiatorAgentId` - - [ ] `initiatorSessionId` - - [ ] `discussGuide` - - [ ] `status` - - [ ] `createdAt` - - [ ] `completedAt` - - [ ] `summaryPath` -- [ ] 提供按 `discussionChannelId` 查询 metadata 的方法 -- [ ] 提供创建 metadata 的方法 -- [ ] 提供更新状态的方法 -- [ ] 提供关闭 discussion channel 的状态写入方法 +- [x] 新建 discussion state 类型定义文件(如 `plugin/core/discussion-state.ts`) +- [x] 定义 discussion metadata 类型: + - [x] `mode` + - [x] `discussionChannelId` + - [x] `originChannelId` + - [x] `initiatorAgentId` + - [x] `initiatorSessionId` + - [x] `discussGuide` + - [x] `status` + - [x] `createdAt` + - [x] `completedAt` + - [x] `summaryPath` +- [x] 提供按 `discussionChannelId` 查询 metadata 的方法 +- [x] 提供创建 metadata 的方法 +- [x] 提供更新状态的方法 +- [x] 提供关闭 discussion channel 的状态写入方法 #### A4.2 新建 discussion service 模块 -- [ ] 新建 discussion service(如 `plugin/core/discussion-service.ts`) -- [ ] 封装 discussion channel 创建后的初始化逻辑 -- [ ] 封装 callback 校验逻辑 -- [ ] 封装 callback 成功后的收尾逻辑 -- [ ] 封装 origin channel moderator 通知逻辑 -- [ ] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑 +- [x] 新建 discussion service(如 `plugin/core/discussion-service.ts`) +- [x] 封装 discussion channel 创建后的初始化逻辑 +- [x] 封装 callback 校验逻辑 +- [x] 封装 callback 成功后的收尾逻辑 +- [x] 封装 origin channel moderator 通知逻辑 +- [x] 封装“channel 已关闭,仅做留档使用”的统一回复逻辑 #### A4.3 workspace 路径校验 -- [ ] 新增 path 校验辅助函数 -- [ ] 校验 `summaryPath` 文件存在 -- [ ] 校验 `summaryPath` 位于 initiator workspace 下 -- [ ] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题 +- [x] 新增 path 校验辅助函数 +- [x] 校验 `summaryPath` 文件存在 +- [x] 校验 `summaryPath` 位于 initiator workspace 下 +- [x] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题 ### A5. `plugin/core/moderator-discord.ts` - [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程 @@ -236,10 +236,10 @@ ### B2. 配置与 schema #### B2.1 `plugin/openclaw.plugin.json` -- [ ] 增加 `multiMessageStartMarker` -- [ ] 增加 `multiMessageEndMarker` -- [ ] 增加 `multiMessagePromptMarker` -- [ ] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️` +- [x] 增加 `multiMessageStartMarker` +- [x] 增加 `multiMessageEndMarker` +- [x] 增加 `multiMessagePromptMarker` +- [x] 为新增配置设置默认值:`↗️` / `↙️` / `⤵️` - [ ] 评估是否需要增加 shuffle 默认配置项 #### B2.2 `plugin/rules.ts` / config 类型 @@ -248,36 +248,36 @@ - [ ] 确保运行时读取配置逻辑可访问新增字段 ### B3. `plugin/core/` 新增 channel mode / shuffle state 模块 -- [ ] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`) -- [ ] 定义 channel mode:`normal` / `multi-message` -- [ ] 提供 `enterMultiMessageMode(channelId)` -- [ ] 提供 `exitMultiMessageMode(channelId)` -- [ ] 提供 `isMultiMessageMode(channelId)` -- [ ] 提供 shuffle 开关状态存取方法 -- [ ] 评估 shuffle state 是否应并入 turn-manager 内部状态 +- [x] 新增 channel mode state 模块(如 `plugin/core/channel-modes.ts`) +- [x] 定义 channel mode:`normal` / `multi-message` +- [x] 提供 `enterMultiMessageMode(channelId)` +- [x] 提供 `exitMultiMessageMode(channelId)` +- [x] 提供 `isMultiMessageMode(channelId)` +- [x] 提供 shuffle 开关状态存取方法 +- [x] 评估 shuffle state 是否应并入 turn-manager 内部状态 ### B4. `plugin/hooks/message-received.ts` #### B4.1 Multi-Message Mode 入口/出口 -- [ ] 检测 human 消息中的 multi-message start marker -- [ ] start marker 命中时,将 channel 切换到 multi-message mode -- [ ] 检测 human 消息中的 multi-message end marker -- [ ] end marker 命中时,将 channel 退出 multi-message mode -- [ ] 避免 moderator 自己的 prompt marker 消息触发 mode 切换 +- [.] 检测 human 消息中的 multi-message start marker +- [.] start marker 命中时,将 channel 切换到 multi-message mode +- [.] 检测 human 消息中的 multi-message end marker +- [.] end marker 命中时,将 channel 退出 multi-message mode +- [.] 避免 moderator 自己的 prompt marker 消息触发 mode 切换 #### B4.2 Multi-Message Mode 中的 moderator 提示 -- [ ] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker -- [ ] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker` -- [ ] 避免重复触发或回环 +- [.] 当 channel 处于 multi-message mode 时,人类每发一条消息触发 moderator prompt marker +- [.] prompt marker 文案/内容使用配置项 `multiMessagePromptMarker` +- [.] 避免重复触发或回环 #### B4.3 与现有 mention override 的兼容 - [ ] 明确 multi-message mode 下 human @mention 是否忽略 - [ ] 避免 multi-message mode 与 mention override 冲突 ### B5. `plugin/hooks/before-model-resolve.ts` -- [ ] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel` -- [ ] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策 -- [ ] 确保退出 multi-message mode 后恢复正常 turn 逻辑 -- [ ] 补充必要调试日志 +- [.] 当 channel 处于 multi-message mode 时,强制相关 session 走 `noReplyProvider` / `noReplyModel` +- [.] 确保 multi-message mode 的 no-reply 覆盖优先于普通 turn 决策 +- [.] 确保退出 multi-message mode 后恢复正常 turn 逻辑 +- [.] 补充必要调试日志 ### B6. `plugin/turn-manager.ts` #### B6.1 Multi-Message Mode 与 turn pause/resume @@ -287,22 +287,22 @@ - [ ] 退出时确定下一位 speaker 的选择逻辑 #### B6.2 Shuffle Mode -- [ ] 为每个 channel 增加 `shuffling` 开关状态 -- [ ] 识别“一轮最后一位 speaker 发言完成”的边界点 -- [ ] 在进入下一轮前执行 reshuffle -- [ ] 保证上一轮最后 speaker 不会成为新一轮第一位 -- [ ] 处理单 Agent 场景 -- [ ] 处理双 Agent 场景 -- [ ] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界 +- [.] 为每个 channel 增加 `shuffling` 开关状态 +- [.] 识别“一轮最后位 speaker 发言完成”的边界点 +- [.] 在进入下一轮前执行 reshuffle +- [.] 保证上一轮最后 speaker 不会成为新一轮第一位 +- [.] 处理单 Agent 场景 +- [.] 处理双 Agent 场景 +- [.] 处理 mention override / waiting-for-human / dormant 状态下的 reshape 边界 ### B7. `plugin/commands/dirigent-command.ts` -- [ ] 新增 `/turn-shuffling` 子命令 -- [ ] 支持: - - [ ] `/turn-shuffling` - - [ ] `/turn-shuffling on` - - [ ] `/turn-shuffling off` -- [ ] 命令返回当前 channel 的 shuffling 状态 -- [ ] 命令帮助文本补充说明 +- [x] 新增 `/turn-shuffling` 子命令 +- [x] 支持: + - [x] `/turn-shuffling` + - [x] `/turn-shuffling on` + - [x] `/turn-shuffling off` +- [x] 命令返回当前 channel 的 shuffling 状态 +- [x] 命令帮助文本补充说明 ### B8. `plugin/index.ts` - [ ] 注入 channel mode / shuffle state 模块依赖 diff --git a/plugin/commands/dirigent-command.ts b/plugin/commands/dirigent-command.ts index 7c6cb29..856665e 100644 --- a/plugin/commands/dirigent-command.ts +++ b/plugin/commands/dirigent-command.ts @@ -1,6 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js"; import type { DirigentConfig } from "../rules.js"; +import { setChannelShuffling, getChannelShuffling } from "../core/channel-modes.js"; type CommandDeps = { api: OpenClawPluginApi; @@ -30,6 +31,7 @@ export function registerDirigentCommand(deps: CommandDeps): void { `/dirigent turn-status - Show turn-based speaking status\n` + `/dirigent turn-advance - Manually advance turn\n` + `/dirigent turn-reset - Reset turn order\n` + + `/dirigent turn-shuffling [on|off] - Enable/disable turn order shuffling\n` + `/dirigent_policy get \n` + `/dirigent_policy set \n` + `/dirigent_policy delete `, @@ -60,6 +62,25 @@ export function registerDirigentCommand(deps: CommandDeps): void { return { text: JSON.stringify({ ok: true }) }; } + if (subCmd === "turn-shuffling") { + const channelId = cmdCtx.channelId; + if (!channelId) return { text: "Cannot get channel ID", isError: true }; + + const arg = parts[1]?.toLowerCase(); + if (arg === "on") { + setChannelShuffling(channelId, true); + return { text: JSON.stringify({ ok: true, channelId, shuffling: true }) }; + } else if (arg === "off") { + setChannelShuffling(channelId, false); + return { text: JSON.stringify({ ok: true, channelId, shuffling: false }) }; + } else if (!arg) { + const isShuffling = getChannelShuffling(channelId); + return { text: JSON.stringify({ ok: true, channelId, shuffling: isShuffling }) }; + } else { + return { text: "Invalid argument. Use: /dirigent turn-shuffling [on|off]", isError: true }; + } + } + return { text: `Unknown subcommand: ${subCmd}`, isError: true }; }, }); diff --git a/plugin/core/channel-modes.ts b/plugin/core/channel-modes.ts new file mode 100644 index 0000000..e41cedd --- /dev/null +++ b/plugin/core/channel-modes.ts @@ -0,0 +1,51 @@ +export type ChannelMode = "normal" | "multi-message"; + +export type ChannelModesState = { + mode: ChannelMode; + shuffling: boolean; + lastShuffledAt?: number; +}; + +const channelStates = new Map(); + +export function getChannelState(channelId: string): ChannelModesState { + if (!channelStates.has(channelId)) { + channelStates.set(channelId, { + mode: "normal", + shuffling: false, + }); + } + return channelStates.get(channelId)!; +} + +export function enterMultiMessageMode(channelId: string): void { + const state = getChannelState(channelId); + state.mode = "multi-message"; + channelStates.set(channelId, state); +} + +export function exitMultiMessageMode(channelId: string): void { + const state = getChannelState(channelId); + state.mode = "normal"; + channelStates.set(channelId, state); +} + +export function isMultiMessageMode(channelId: string): boolean { + return getChannelState(channelId).mode === "multi-message"; +} + +export function setChannelShuffling(channelId: string, enabled: boolean): void { + const state = getChannelState(channelId); + state.shuffling = enabled; + channelStates.set(channelId, state); +} + +export function getChannelShuffling(channelId: string): boolean { + return getChannelState(channelId).shuffling; +} + +export function markLastShuffled(channelId: string): void { + const state = getChannelState(channelId); + state.lastShuffledAt = Date.now(); + channelStates.set(channelId, state); +} \ No newline at end of file diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts index 08c08af..f0d0e4d 100644 --- a/plugin/hooks/before-message-write.ts +++ b/plugin/hooks/before-message-write.ts @@ -1,6 +1,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { resolvePolicy, type DirigentConfig } from "../rules.js"; import { getTurnDebugInfo, onSpeakerDone, setWaitingForHuman } from "../turn-manager.js"; +import { isMultiMessageMode } from "../core/channel-modes.js"; type DebugConfig = { enableDebugLogs?: boolean; @@ -181,15 +182,23 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo } if (live.moderatorBotToken) { - const nextUserId = resolveDiscordUserId(api, nextSpeaker); - if (nextUserId) { - const schedulingId = live.schedulingIdentifier || "➡️"; - const handoffMsg = `<@${nextUserId}>${schedulingId}`; - void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { - api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`); + if (isMultiMessageMode(channelId)) { + // In multi-message mode, send the prompt marker instead of scheduling identifier + const promptMarker = live.multiMessagePromptMarker || "⤵️"; + void sendModeratorMessage(live.moderatorBotToken, channelId, promptMarker, api.logger).catch((err) => { + api.logger.warn(`dirigent: before_message_write multi-message prompt marker failed: ${String(err)}`); }); } else { - api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + const nextUserId = resolveDiscordUserId(api, nextSpeaker); + if (nextUserId) { + const schedulingId = live.schedulingIdentifier || "➡️"; + const handoffMsg = `<@${nextUserId}>${schedulingId}`; + void sendModeratorMessage(live.moderatorBotToken, channelId, handoffMsg, api.logger).catch((err) => { + api.logger.warn(`dirigent: before_message_write handoff failed: ${String(err)}`); + }); + } else { + api.logger.warn(`dirigent: cannot resolve Discord userId for next speaker accountId=${nextSpeaker}`); + } } } } else if (hasEndSymbol) { diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts index 71d4dc6..868a038 100644 --- a/plugin/hooks/before-model-resolve.ts +++ b/plugin/hooks/before-model-resolve.ts @@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, type Decision, type DirigentConfig } from "../rules.js"; import { checkTurn } from "../turn-manager.js"; import { deriveDecisionInputFromPrompt } from "../decision-input.js"; +import { isMultiMessageMode } from "../core/channel-modes.js"; type DebugConfig = { enableDebugLogs?: boolean; @@ -99,6 +100,16 @@ export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): vo noReply: true, }; } + + if (isMultiMessageMode(derived.channelId)) { + sessionAllowed.set(key, false); + api.logger.info(`dirigent: before_model_resolve forcing no-reply for multi-message mode channel=${derived.channelId} session=${key}`); + return { + model: ctx.model, + provider: ctx.provider, + noReply: true, + }; + } } const resolvedAccountId = resolveAccountId(api, ctx.agentId || ""); if (resolvedAccountId) { diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts index 2126324..76f66bf 100644 --- a/plugin/hooks/message-received.ts +++ b/plugin/hooks/message-received.ts @@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { onNewMessage, setMentionOverride, getTurnDebugInfo } from "../turn-manager.js"; import { extractDiscordChannelId } from "../channel-resolver.js"; import type { DirigentConfig } from "../rules.js"; +import { enterMultiMessageMode, exitMultiMessageMode, isMultiMessageMode } from "../core/channel-modes.js"; type DebugConfig = { enableDebugLogs?: boolean; @@ -79,21 +80,38 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { if (isHuman) { const messageContent = ((e as Record).content as string) || ((e as Record).text as string) || ""; - const mentionedUserIds = extractMentionedUserIds(messageContent); + + // Handle multi-message mode markers + const startMarker = livePre.multiMessageStartMarker || "↗️"; + const endMarker = livePre.multiMessageEndMarker || "↙️"; + + if (messageContent.includes(startMarker)) { + enterMultiMessageMode(preChannelId); + api.logger.info(`dirigent: entered multi-message mode channel=${preChannelId}`); + } else if (messageContent.includes(endMarker)) { + exitMultiMessageMode(preChannelId); + api.logger.info(`dirigent: exited multi-message mode channel=${preChannelId}`); + // After exiting multi-message mode, activate the turn system + onNewMessage(preChannelId, senderAccountId, isHuman); + } else { + const mentionedUserIds = extractMentionedUserIds(messageContent); - if (mentionedUserIds.length > 0) { - const userIdMap = buildUserIdToAccountIdMap(api); - const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid); + if (mentionedUserIds.length > 0) { + const userIdMap = buildUserIdToAccountIdMap(api); + const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid); - if (mentionedAccountIds.length > 0) { - await ensureTurnOrder(api, preChannelId); - const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds); - if (overrideSet) { - api.logger.info( - `dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`, - ); - if (shouldDebugLog(livePre, preChannelId)) { - api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`); + if (mentionedAccountIds.length > 0) { + await ensureTurnOrder(api, preChannelId); + const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds); + if (overrideSet) { + api.logger.info( + `dirigent: mention override set channel=${preChannelId} mentionedAgents=${JSON.stringify(mentionedAccountIds)}`, + ); + if (shouldDebugLog(livePre, preChannelId)) { + api.logger.info(`dirigent: turn state after override: ${JSON.stringify(getTurnDebugInfo(preChannelId))}`); + } + } else { + onNewMessage(preChannelId, senderAccountId, isHuman); } } else { onNewMessage(preChannelId, senderAccountId, isHuman); @@ -101,8 +119,6 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { } else { onNewMessage(preChannelId, senderAccountId, isHuman); } - } else { - onNewMessage(preChannelId, senderAccountId, isHuman); } } else { onNewMessage(preChannelId, senderAccountId, isHuman); diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index ba0c07f..f42b380 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -25,7 +25,10 @@ "enableDirigentPolicyTool": { "type": "boolean", "default": true }, "enableDebugLogs": { "type": "boolean", "default": false }, "debugLogChannelIds": { "type": "array", "items": { "type": "string" }, "default": [] }, - "moderatorBotToken": { "type": "string" } + "moderatorBotToken": { "type": "string" }, + "multiMessageStartMarker": { "type": "string", "default": "↗️" }, + "multiMessageEndMarker": { "type": "string", "default": "↙️" }, + "multiMessagePromptMarker": { "type": "string", "default": "⤵️" } }, "required": ["noReplyProvider", "noReplyModel"] } diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 121d68a..883678a 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -11,6 +11,8 @@ * - If sender IS in turn order → current = next after sender */ +import { isMultiMessageMode, exitMultiMessageMode } from "./core/channel-modes.js"; + export type ChannelTurnState = { /** Ordered accountIds for this channel (auto-populated, shuffled) */ turnOrder: string[]; @@ -46,6 +48,26 @@ function shuffleArray(arr: T[]): T[] { return a; } +function reshuffleTurnOrder(channelId: string, currentOrder: string[], lastSpeaker?: string): string[] { + const shufflingEnabled = getChannelShuffling(channelId); + if (!shufflingEnabled) return currentOrder; + + const shuffled = shuffleArray(currentOrder); + + // If there's a last speaker and they're in the order, ensure they're not first + if (lastSpeaker && shuffled.length > 1 && shuffled[0] === lastSpeaker) { + // Find another speaker to swap with + for (let i = 1; i < shuffled.length; i++) { + if (shuffled[i] !== lastSpeaker) { + [shuffled[0], shuffled[i]] = [shuffled[i], shuffled[0]]; + break; + } + } + } + + return shuffled; +} + // --- public API --- /** @@ -176,6 +198,13 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) return; + // Check for multi-message mode exit condition + if (isMultiMessageMode(channelId) && isHuman) { + // In multi-message mode, human messages don't trigger turn activation + // We only exit multi-message mode if the end marker is detected in a higher-level hook + return; + } + if (isHuman) { // Human message: clear wait-for-human, restore original order if overridden, activate from first state.waitingForHuman = false; @@ -330,6 +359,7 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: state.noRepliedThisCycle = new Set(); } + const prevSpeaker = state.currentSpeaker; const next = advanceTurn(channelId); // Check if override cycle completed (returned to first agent) @@ -341,6 +371,18 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: return null; // go dormant after override cycle completes } + // Check if we've completed a full cycle (all agents spoke once) + // This happens when we're back to the first agent in the turn order + const isFirstSpeakerAgain = next === state.turnOrder[0]; + if (!wasNoReply && !state.overrideFirstAgent && next && isFirstSpeakerAgain && state.noRepliedThisCycle.size === 0) { + // Completed a full cycle without anyone NO_REPLYing - reshuffle if enabled + const newOrder = reshuffleTurnOrder(channelId, state.turnOrder, prevSpeaker); + if (newOrder !== state.turnOrder) { + state.turnOrder = newOrder; + console.log(`[dirigent][turn-debug] reshuffled turn order for channel=${channelId} newOrder=${JSON.stringify(newOrder)}`); + } + } + return next; }