From 0729e83b38d3e0cec04135329c6ba912310c5ea5 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 7 Mar 2026 16:55:01 +0000 Subject: [PATCH] feat: split dirigent_tools into individual tools + human @mention override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1: Split dirigent_tools - Replace monolithic dirigent_tools (9 actions) with 9 individual tools - Discord: dirigent_channel_create, dirigent_channel_update, dirigent_member_list - Policy: dirigent_policy_get, dirigent_policy_set, dirigent_policy_delete - Turn: dirigent_turn_status, dirigent_turn_advance, dirigent_turn_reset - Extract shared executeDiscordAction() helper Feature 2: Human @mention override - When humanList user @mentions agents, temporarily override turn order - Only mentioned agents cycle, ordered by their turn order position - Original order restores when cycle returns to first agent or all NO_REPLY - New: setMentionOverride(), hasMentionOverride(), extractMentionedUserIds() - New: buildUserIdToAccountIdMap() for reverse userId→accountId resolution Bump version to 0.3.0 --- CHANGELOG.md | 12 ++ NEW_FEAT.md | 4 +- TASKLIST.md | 40 +++- package.json | 2 +- plugin/index.ts | 405 +++++++++++++++++++++++++++--------- plugin/openclaw.plugin.json | 2 +- plugin/turn-manager.ts | 84 +++++++- 7 files changed, 436 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c828e07..01c053a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.3.0 + +- **Split `dirigent_tools` into individual tools**: Each action is now a separate tool with focused parameters: + - `dirigent_channel_create`, `dirigent_channel_update`, `dirigent_member_list` (Discord control) + - `dirigent_policy_get`, `dirigent_policy_set`, `dirigent_policy_delete` (policy management) + - `dirigent_turn_status`, `dirigent_turn_advance`, `dirigent_turn_reset` (turn management) +- **Human @mention override**: When a `humanList` user @mentions specific agents: + - Temporarily overrides the speaking order to only mentioned agents + - Agents cycle in their original turn-order position + - After all mentioned agents have spoken, original order restores and state goes dormant + - Handles edge cases: all NO_REPLY, reset, non-agent mentions + ## 0.2.0 - **Project renamed from WhisperGate to Dirigent** diff --git a/NEW_FEAT.md b/NEW_FEAT.md index db7024b..bda8407 100644 --- a/NEW_FEAT.md +++ b/NEW_FEAT.md @@ -1,4 +1,4 @@ # New Features -1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。 -2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。 +1. 拆分 dirigent-tools:不再用一个主工具管理多个小工具。 ✅ +2. 人类@规则:当来自 humanList 的用户消息包含 <@USER_ID> 时,按被 @ 的 agent 顺序临时覆盖 speaking order 循环;回到首个 agent 后恢复原顺序。 ✅ diff --git a/TASKLIST.md b/TASKLIST.md index 65d0109..b9968dd 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -39,11 +39,39 @@ --- +## 6) Split dirigent_tools into Individual Tools ✅ +- **Before**: Single `dirigent_tools` tool with `action` parameter managing 9 sub-actions. +- **After**: 9 individual tools, each with focused parameters: + - `dirigent_channel_create` — Create private Discord channel + - `dirigent_channel_update` — Update channel permissions + - `dirigent_member_list` — List guild members + - `dirigent_policy_get` — Get all channel policies + - `dirigent_policy_set` — Set/update a channel policy + - `dirigent_policy_delete` — Delete a channel policy + - `dirigent_turn_status` — Show turn status + - `dirigent_turn_advance` — Manually advance turn + - `dirigent_turn_reset` — Reset turn order +- Shared Discord API helper `executeDiscordAction()` extracted to reduce duplication. +- **Done**: All tools registered individually with specific parameter schemas. + +## 7) Human @Mention Override ✅ +- When a message from a `humanList` user contains `<@USER_ID>` mentions: + - Extract mentioned Discord user IDs from the message content. + - Map user IDs → accountIds via bot token decoding (reverse of `resolveDiscordUserId`). + - Filter to agents in the current turn order. + - Order by their position in the current turn order. + - Temporarily replace the speaking order with only those agents. + - After the cycle returns to the first agent, restore the original order and go dormant. +- Edge cases handled: + - All override agents NO_REPLY → restore original order, go dormant. + - `resetTurn` clears any active override. + - New human message without mentions → restores override before normal handling. + - Mentioned users not in turn order → ignored, normal flow. +- New functions in `turn-manager.ts`: `setMentionOverride()`, `hasMentionOverride()`. +- New helpers in `index.ts`: `buildUserIdToAccountIdMap()`, `extractMentionedUserIds()`. +- **Done**: Override logic integrated in `message_received` handler. + +--- + ## Open Items / Notes - User requested the previous README commit should have been pushed to `main` directly (was pushed to a branch). Address separately if needed. -- **New feature: Human @ list override** - - When a message is from a user in `humanList` and contains `<@USER_ID>` mentions: - - Detect which agents are @-mentioned (e.g., a, b, c). - - Determine their order in the current speaking order list (e.g., a → b → c). - - Temporarily replace the speaking order with `[a, b, c]` and cycle a → b → c. - - After the cycle returns to **a** again, restore the original speaking order list. diff --git a/package.json b/package.json index 44692d6..abe34d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hangman-lab/dirigent", - "version": "0.2.0", + "version": "0.3.0", "description": "Dirigent - Rule-based no-reply gate with provider/model override and turn management for OpenClaw", "type": "module", "files": [ diff --git a/plugin/index.ts b/plugin/index.ts index a137357..bdcc33c 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type DirigentConfig } from "./rules.js"; -import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo } from "./turn-manager.js"; +import { checkTurn, advanceTurn, resetTurn, onNewMessage, onSpeakerDone, initTurnOrder, getTurnDebugInfo, setMentionOverride, hasMentionOverride } from "./turn-manager.js"; import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js"; // ── No-Reply API child process lifecycle ────────────────────────────── @@ -409,6 +409,44 @@ function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): string return userIdFromToken(acct.token); } +/** + * Build a reverse map: Discord userId → accountId for all configured Discord accounts. + */ +function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map { + const root = (api.config as Record) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + const map = new Map(); + for (const [accountId, acct] of Object.entries(accounts)) { + if (typeof acct.token === "string") { + const userId = userIdFromToken(acct.token); + if (userId) map.set(userId, accountId); + } + } + return map; +} + +/** + * Extract Discord @mention user IDs from message content. + * Matches <@USER_ID> and <@!USER_ID> patterns. + * Returns user IDs in the order they appear. + */ +function extractMentionedUserIds(content: string): string[] { + const regex = /<@!?(\d+)>/g; + const ids: string[] = []; + const seen = new Set(); + let match; + while ((match = regex.exec(content)) !== null) { + const id = match[1]; + if (!seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + return ids; +} + /** Get the moderator bot's Discord user ID from its token */ function getModeratorUserId(config: DirigentConfig): string | undefined { if (!config.moderatorBotToken) return undefined; @@ -555,18 +593,44 @@ export default { api.logger.info("dirigent: gateway stopping, services shut down"); }); + // ── Helper: execute Discord control API action ── + async function executeDiscordAction(action: DiscordControlAction, params: Record) { + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { + discordControlApiBaseUrl?: string; + discordControlApiToken?: string; + discordControlCallerId?: string; + enableDiscordControlTool?: boolean; + }; + if (live.enableDiscordControlTool === false) { + return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; + } + const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); + const body = pickDefined({ ...params, action }); + const headers: Record = { "Content-Type": "application/json" }; + if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; + if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; + const r = await fetch(`${baseUrl}/v1/discord/action`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + const text = await r.text(); + if (!r.ok) { + return { content: [{ type: "text", text: `discord action failed (${r.status}): ${text}` }], isError: true }; + } + return { content: [{ type: "text", text }] }; + } + + // ── Discord control tools ── + api.registerTool( { - name: "dirigent_tools", - description: "Dirigent unified tool: Discord admin actions + in-memory policy management.", + name: "dirigent_channel_create", + description: "Create a private Discord channel with specific user/role permissions.", parameters: { type: "object", additionalProperties: false, properties: { - action: { - type: "string", - 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" }, name: { type: "string" }, type: { type: "number" }, @@ -578,127 +642,224 @@ export default { allowedRoleIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" }, denyEveryoneMask: { type: "string" }, + }, + required: [], + }, + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-private-create", params); + }, + }, + { optional: false }, + ); + + api.registerTool( + { + name: "dirigent_channel_update", + description: "Update permissions on an existing private Discord channel.", + parameters: { + type: "object", + additionalProperties: false, + properties: { channelId: { type: "string" }, mode: { type: "string", enum: ["merge", "replace"] }, addUserIds: { type: "array", items: { type: "string" } }, addRoleIds: { type: "array", items: { type: "string" } }, removeTargetIds: { type: "array", items: { type: "string" } }, + allowMask: { type: "string" }, denyMask: { type: "string" }, + }, + required: [], + }, + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-private-update", params); + }, + }, + { optional: false }, + ); + + api.registerTool( + { + name: "dirigent_member_list", + description: "List members of a Discord guild.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + guildId: { type: "string" }, limit: { type: "number" }, after: { type: "string" }, fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, - dryRun: { type: "boolean" }, + }, + required: [], + }, + async execute(_id: string, params: Record) { + return executeDiscordAction("member-list", params); + }, + }, + { optional: false }, + ); + + // ── Policy tools ── + + api.registerTool( + { + name: "dirigent_policy_get", + description: "Get all Dirigent channel policies.", + parameters: { type: "object", additionalProperties: false, properties: {}, required: [] }, + async execute() { + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; + if (live.enableDirigentPolicyTool === false) { + return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; + } + ensurePolicyStateLoaded(api, live); + return { + content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], + }; + }, + }, + { optional: false }, + ); + + api.registerTool( + { + name: "dirigent_policy_set", + description: "Set or update a Dirigent channel policy.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + channelId: { type: "string" }, listMode: { type: "string", enum: ["human-list", "agent-list"] }, humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, }, - required: ["action"], + required: ["channelId"], }, async execute(_id: string, params: Record) { - const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { - discordControlApiBaseUrl?: string; - discordControlApiToken?: string; - discordControlCallerId?: string; - enableDiscordControlTool?: boolean; - enableDirigentPolicyTool?: boolean; - }; - ensurePolicyStateLoaded(api, live); - - const action = String(params.action || ""); - const discordActions = new Set(["channel-private-create", "channel-private-update", "member-list"]); - - if (discordActions.has(action)) { - if (live.enableDiscordControlTool === false) { - return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; - } - const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); - const body = pickDefined({ ...params, action: action as DiscordControlAction }); - const headers: Record = { "Content-Type": "application/json" }; - if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; - if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; - - const r = await fetch(`${baseUrl}/v1/discord/action`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - const text = await r.text(); - if (!r.ok) { - return { - content: [{ type: "text", text: `dirigent_tools discord failed (${r.status}): ${text}` }], - isError: true, - }; - } - return { content: [{ type: "text", text }] }; - } - + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; if (live.enableDirigentPolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } - - if (action === "policy-get") { - return { - content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], + ensurePolicyStateLoaded(api, live); + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); + try { + const next: ChannelPolicy = { + listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, + 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, }; + policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; + persistPolicies(api); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; + } catch (err) { + policyState.channelPolicies = prev; + return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } + }, + }, + { optional: false }, + ); - if (action === "policy-set-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - const next: ChannelPolicy = { - listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, - 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, - }; - policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } + api.registerTool( + { + name: "dirigent_policy_delete", + description: "Delete a Dirigent channel policy.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + channelId: { type: "string" }, + }, + required: ["channelId"], + }, + async execute(_id: string, params: Record) { + const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; + if (live.enableDirigentPolicyTool === false) { + return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } - - if (action === "policy-delete-channel") { - const channelId = String(params.channelId || "").trim(); - if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; - const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); - try { - delete policyState.channelPolicies[channelId]; - persistPolicies(api); - return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; - } catch (err) { - policyState.channelPolicies = prev; - return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; - } + ensurePolicyStateLoaded(api, live); + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); + try { + delete policyState.channelPolicies[channelId]; + persistPolicies(api); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; + } catch (err) { + policyState.channelPolicies = prev; + return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } + }, + }, + { optional: false }, + ); - 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) }] }; - } + // ── Turn management tools ── - 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) }) }] }; - } + api.registerTool( + { + name: "dirigent_turn_status", + description: "Show turn-based speaking status for a channel.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + channelId: { type: "string" }, + }, + required: ["channelId"], + }, + async execute(_id: string, params: Record) { + 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) }] }; + }, + }, + { optional: false }, + ); - 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) }) }] }; - } + api.registerTool( + { + name: "dirigent_turn_advance", + description: "Manually advance the speaking turn in a channel.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + channelId: { type: "string" }, + }, + required: ["channelId"], + }, + async execute(_id: string, params: Record) { + 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) }) }] }; + }, + }, + { optional: false }, + ); - return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; + api.registerTool( + { + name: "dirigent_turn_reset", + description: "Reset turn order for a channel (go dormant).", + parameters: { + type: "object", + additionalProperties: false, + properties: { + channelId: { type: "string" }, + }, + required: ["channelId"], + }, + async execute(_id: string, params: Record) { + 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) }) }] }; }, }, { optional: false }, @@ -747,7 +908,49 @@ export default { } } - onNewMessage(preChannelId, senderAccountId, isHuman); + // Human @mention override: when a human mentions specific agents, + // temporarily override the turn order to only those agents. + if (isHuman) { + const messageContent = (e as Record).content as string + || (e as Record).text as string + || ""; + const mentionedUserIds = extractMentionedUserIds(messageContent); + + if (mentionedUserIds.length > 0) { + // Build reverse map: userId → accountId + const userIdMap = buildUserIdToAccountIdMap(api); + // Exclude moderator bot from mention targets + const mentionedAccountIds = mentionedUserIds + .map(uid => userIdMap.get(uid)) + .filter((aid): aid is string => !!aid); + + if (mentionedAccountIds.length > 0) { + 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))}`); + } + // Skip normal onNewMessage — override already set currentSpeaker + } else { + // No valid agents in mentions, fall through to normal handling + onNewMessage(preChannelId, senderAccountId, isHuman); + } + } else { + // Mentioned users aren't agents, normal human message + onNewMessage(preChannelId, senderAccountId, isHuman); + } + } else { + // No mentions, normal human message + onNewMessage(preChannelId, senderAccountId, isHuman); + } + } else { + onNewMessage(preChannelId, senderAccountId, isHuman); + } + if (shouldDebugLog(livePre, preChannelId)) { api.logger.info(`dirigent: turn onNewMessage channel=${preChannelId} from=${from} isHuman=${isHuman} accountId=${senderAccountId ?? "unknown"}`); } diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 1886edf..ae77c34 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "dirigent", "name": "Dirigent", - "version": "0.2.0", + "version": "0.3.0", "description": "Rule-based no-reply gate with provider/model override and turn management", "entry": "./index.ts", "configSchema": { diff --git a/plugin/turn-manager.ts b/plugin/turn-manager.ts index 033699e..52e510d 100644 --- a/plugin/turn-manager.ts +++ b/plugin/turn-manager.ts @@ -20,6 +20,11 @@ export type ChannelTurnState = { noRepliedThisCycle: Set; /** Timestamp of last state change */ lastChangedAt: number; + // ── Mention override state ── + /** Original turn order saved when override is active */ + savedTurnOrder?: string[]; + /** First agent in override cycle; used to detect cycle completion */ + overrideFirstAgent?: string; }; const channelTurns = new Map(); @@ -107,6 +112,8 @@ export function checkTurn(channelId: string, accountId: string): { * Called when a new message arrives in the channel. * Handles reactivation from dormant state and human-triggered resets. * + * NOTE: For human messages with @mentions, call setMentionOverride() instead. + * * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) * @param isHuman - whether the sender is in the humanList */ @@ -115,7 +122,8 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi if (!state || state.turnOrder.length === 0) return; if (isHuman) { - // Human message: activate, start from first in order + // Human message without @mentions: restore original order if overridden, activate from first + restoreOriginalOrder(state); state.currentSpeaker = state.turnOrder[0]; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); @@ -141,6 +149,61 @@ export function onNewMessage(channelId: string, senderAccountId: string | undefi state.lastChangedAt = Date.now(); } +/** + * Restore original turn order if an override is active. + */ +function restoreOriginalOrder(state: ChannelTurnState): void { + if (state.savedTurnOrder) { + state.turnOrder = state.savedTurnOrder; + state.savedTurnOrder = undefined; + state.overrideFirstAgent = undefined; + } +} + +/** + * Set a temporary mention override for the turn order. + * When a human @mentions specific agents, only those agents speak (in their + * relative order from the current turn order). After the cycle returns to the + * first agent, the original order is restored. + * + * @param channelId - Discord channel ID + * @param mentionedAccountIds - accountIds of @mentioned agents, ordered by + * their position in the current turn order + * @returns true if override was set, false if no valid agents + */ +export function setMentionOverride(channelId: string, mentionedAccountIds: string[]): boolean { + const state = channelTurns.get(channelId); + if (!state || mentionedAccountIds.length === 0) return false; + + // Restore any existing override first + restoreOriginalOrder(state); + + // Filter to agents actually in the turn order + const validIds = mentionedAccountIds.filter(id => state.turnOrder.includes(id)); + if (validIds.length === 0) return false; + + // Order by their position in the current turn order + validIds.sort((a, b) => state.turnOrder.indexOf(a) - state.turnOrder.indexOf(b)); + + // Save original and apply override + state.savedTurnOrder = [...state.turnOrder]; + state.turnOrder = validIds; + state.overrideFirstAgent = validIds[0]; + state.currentSpeaker = validIds[0]; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); + + return true; +} + +/** + * Check if a mention override is currently active. + */ +export function hasMentionOverride(channelId: string): boolean { + const state = channelTurns.get(channelId); + return !!state?.savedTurnOrder; +} + /** * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) @@ -157,6 +220,8 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: // Check if ALL agents have NO_REPLY'd this cycle const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id)); if (allNoReplied) { + // If override active, restore original order before going dormant + restoreOriginalOrder(state); // Go dormant state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); @@ -168,7 +233,18 @@ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: state.noRepliedThisCycle = new Set(); } - return advanceTurn(channelId); + const next = advanceTurn(channelId); + + // Check if override cycle completed (returned to first agent) + if (state.overrideFirstAgent && next === state.overrideFirstAgent) { + restoreOriginalOrder(state); + state.currentSpeaker = null; + state.noRepliedThisCycle = new Set(); + state.lastChangedAt = Date.now(); + return null; // go dormant after override cycle completes + } + + return next; } /** @@ -209,6 +285,7 @@ export function advanceTurn(channelId: string): string | null { export function resetTurn(channelId: string): void { const state = channelTurns.get(channelId); if (state) { + restoreOriginalOrder(state); state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); @@ -229,5 +306,8 @@ export function getTurnDebugInfo(channelId: string): Record { noRepliedThisCycle: [...state.noRepliedThisCycle], lastChangedAt: state.lastChangedAt, dormant: state.currentSpeaker === null, + hasOverride: !!state.savedTurnOrder, + overrideFirstAgent: state.overrideFirstAgent || null, + savedTurnOrder: state.savedTurnOrder || null, }; }