feat: split dirigent_tools into individual tools + human @mention override

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
This commit is contained in:
zhi
2026-03-07 16:55:01 +00:00
parent bbd18cd90c
commit 0729e83b38
7 changed files with 436 additions and 113 deletions

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,127 +642,224 @@ 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) }],
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<string, unknown>) 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<string, unknown>) 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<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 };
}
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<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-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<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 },
);
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<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) }) }] };
},
},
{ 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<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"}`);
}