feat: split dirigent_tools + human @mention override #14

Merged
hzhang merged 37 commits from feat/split-tools-and-mention-override into main 2026-03-08 08:02:28 +00:00
7 changed files with 436 additions and 113 deletions
Showing only changes of commit 0729e83b38 - Show all commits

View File

@@ -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**

View File

@@ -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 后恢复原顺序。

View File

@@ -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.

View File

@@ -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": [

View File

@@ -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<string, string> {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const map = new Map<string, string>();
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<string>();
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<string, unknown>) {
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<string, string> = { "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,75 +642,108 @@ export default {
allowedRoleIds: { type: "array", items: { type: "string" } },
allowMask: { type: "string" },
denyEveryoneMask: { type: "string" },
},
required: [],
},
async execute(_id: string, params: Record<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, string> = { "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) }],
};
}
if (action === "policy-set-channel") {
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 = {
@@ -662,9 +759,29 @@ export default {
policyState.channelPolicies = prev;
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
}
}
},
},
{ optional: false },
);
if (action === "policy-delete-channel") {
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<string, unknown>) {
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);
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));
@@ -676,29 +793,73 @@ export default {
policyState.channelPolicies = prev;
return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true };
}
}
},
},
{ optional: false },
);
if (action === "turn-status") {
// ── Turn management tools ──
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<string, unknown>) {
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-advance") {
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<string, unknown>) {
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 },
);
if (action === "turn-reset") {
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<string, unknown>) {
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 };
},
},
{ optional: false },
@@ -747,7 +908,49 @@ export default {
}
}
// 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<string, unknown>).content as string
|| (e as Record<string, unknown>).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"}`);
}

View File

@@ -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": {

View File

@@ -20,6 +20,11 @@ export type ChannelTurnState = {
noRepliedThisCycle: Set<string>;
/** 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<string, ChannelTurnState>();
@@ -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<string, unknown> {
noRepliedThisCycle: [...state.noRepliedThisCycle],
lastChangedAt: state.lastChangedAt,
dormant: state.currentSpeaker === null,
hasOverride: !!state.savedTurnOrder,
overrideFirstAgent: state.overrideFirstAgent || null,
savedTurnOrder: state.savedTurnOrder || null,
};
}