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
This commit is contained in:
@@ -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 <discordChannelId>\n` +
|
||||
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
|
||||
`/dirigent_policy delete <discordChannelId>`,
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
51
plugin/core/channel-modes.ts
Normal file
51
plugin/core/channel-modes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type ChannelMode = "normal" | "multi-message";
|
||||
|
||||
export type ChannelModesState = {
|
||||
mode: ChannelMode;
|
||||
shuffling: boolean;
|
||||
lastShuffledAt?: number;
|
||||
};
|
||||
|
||||
const channelStates = new Map<string, ChannelModesState>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>).content as string) || ((e as Record<string, unknown>).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);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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<T>(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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user