Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
import type { ChannelStore } from "../core/channel-store.js";
|
|
import type { IdentityRegistry } from "../core/identity-registry.js";
|
|
import { createDiscordChannel, getBotUserIdFromToken } from "../core/moderator-discord.js";
|
|
import { setSpeakerList } from "../turn-manager.js";
|
|
|
|
type ToolDeps = {
|
|
api: OpenClawPluginApi;
|
|
channelStore: ChannelStore;
|
|
identityRegistry: IdentityRegistry;
|
|
moderatorBotToken: string | undefined;
|
|
scheduleIdentifier: string;
|
|
/** Called by create-discussion-channel to initialize the discussion. */
|
|
onDiscussionCreate?: (params: {
|
|
channelId: string;
|
|
guildId: string;
|
|
initiatorAgentId: string;
|
|
callbackGuildId: string;
|
|
callbackChannelId: string;
|
|
discussionGuide: string;
|
|
participants: string[];
|
|
}) => Promise<void>;
|
|
};
|
|
|
|
function getGuildIdFromSessionKey(sessionKey: string): string | undefined {
|
|
// sessionKey doesn't encode guild — it's not available directly.
|
|
// Guild is passed explicitly by the agent.
|
|
return undefined;
|
|
}
|
|
|
|
function parseDiscordChannelIdFromSession(sessionKey: string): string | undefined {
|
|
const m = sessionKey.match(/:discord:channel:(\d+)$/);
|
|
return m?.[1];
|
|
}
|
|
|
|
export function registerDirigentTools(deps: ToolDeps): void {
|
|
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, onDiscussionCreate } = deps;
|
|
|
|
// ───────────────────────────────────────────────
|
|
// dirigent-register
|
|
// ───────────────────────────────────────────────
|
|
api.registerTool({
|
|
name: "dirigent-register",
|
|
description: "Register or update this agent's Discord user ID in Dirigent's identity registry.",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
discordUserId: { type: "string", description: "The agent's Discord user ID" },
|
|
agentName: { type: "string", description: "Display name (optional, defaults to agentId)" },
|
|
},
|
|
required: ["discordUserId"],
|
|
},
|
|
handler: async (params, ctx) => {
|
|
const agentId = ctx?.agentId;
|
|
if (!agentId) return { content: [{ type: "text", text: "Cannot resolve agentId from session context" }], isError: true };
|
|
const p = params as { discordUserId: string; agentName?: string };
|
|
identityRegistry.upsert({
|
|
agentId,
|
|
discordUserId: p.discordUserId,
|
|
agentName: p.agentName ?? agentId,
|
|
});
|
|
return { content: [{ type: "text", text: `Registered: agentId=${agentId} discordUserId=${p.discordUserId}` }] };
|
|
},
|
|
});
|
|
|
|
// ───────────────────────────────────────────────
|
|
// Helper: create channel + set mode
|
|
// ───────────────────────────────────────────────
|
|
async function createManagedChannel(opts: {
|
|
guildId: string;
|
|
name: string;
|
|
memberDiscordIds: string[];
|
|
mode: "chat" | "report" | "work";
|
|
callerCtx: { agentId?: string };
|
|
}): Promise<{ ok: boolean; channelId?: string; error?: string }> {
|
|
if (!moderatorBotToken) return { ok: false, error: "moderatorBotToken not configured" };
|
|
|
|
const botId = getBotUserIdFromToken(moderatorBotToken);
|
|
const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
|
|
{ id: opts.guildId, type: 0, deny: "1024" }, // deny everyone
|
|
];
|
|
if (botId) overwrites.push({ id: botId, type: 1, allow: "1024" });
|
|
for (const uid of opts.memberDiscordIds) {
|
|
if (uid) overwrites.push({ id: uid, type: 1, allow: "1024" });
|
|
}
|
|
|
|
let channelId: string;
|
|
try {
|
|
channelId = await createDiscordChannel({
|
|
token: moderatorBotToken,
|
|
guildId: opts.guildId,
|
|
name: opts.name,
|
|
permissionOverwrites: overwrites,
|
|
logger: api.logger,
|
|
});
|
|
} catch (err) {
|
|
return { ok: false, error: String(err) };
|
|
}
|
|
|
|
try {
|
|
channelStore.setLockedMode(channelId, opts.mode);
|
|
} catch {
|
|
channelStore.setMode(channelId, opts.mode);
|
|
}
|
|
|
|
return { ok: true, channelId };
|
|
}
|
|
|
|
// ───────────────────────────────────────────────
|
|
// create-chat-channel
|
|
// ───────────────────────────────────────────────
|
|
api.registerTool({
|
|
name: "create-chat-channel",
|
|
description: "Create a new private Discord channel in the specified guild with mode=chat.",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
guildId: { type: "string", description: "Guild ID to create the channel in" },
|
|
name: { type: "string", description: "Channel name" },
|
|
participants: {
|
|
type: "array", items: { type: "string" },
|
|
description: "Discord user IDs to add (moderator bot always added)",
|
|
},
|
|
},
|
|
required: ["guildId", "name"],
|
|
},
|
|
handler: async (params, ctx) => {
|
|
const p = params as { guildId: string; name: string; participants?: string[] };
|
|
const result = await createManagedChannel({
|
|
guildId: p.guildId, name: p.name,
|
|
memberDiscordIds: p.participants ?? [],
|
|
mode: "chat",
|
|
callerCtx: { agentId: ctx?.agentId },
|
|
});
|
|
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
|
return { content: [{ type: "text", text: `Created chat channel: ${result.channelId}` }] };
|
|
},
|
|
});
|
|
|
|
// ───────────────────────────────────────────────
|
|
// create-report-channel
|
|
// ───────────────────────────────────────────────
|
|
api.registerTool({
|
|
name: "create-report-channel",
|
|
description: "Create a new private Discord channel with mode=report. Agents can post to it but are not woken by messages.",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
guildId: { type: "string", description: "Guild ID" },
|
|
name: { type: "string", description: "Channel name" },
|
|
members: { type: "array", items: { type: "string" }, description: "Discord user IDs to add" },
|
|
},
|
|
required: ["guildId", "name"],
|
|
},
|
|
handler: async (params, ctx) => {
|
|
const p = params as { guildId: string; name: string; members?: string[] };
|
|
const result = await createManagedChannel({
|
|
guildId: p.guildId, name: p.name,
|
|
memberDiscordIds: p.members ?? [],
|
|
mode: "report",
|
|
callerCtx: { agentId: ctx?.agentId },
|
|
});
|
|
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
|
return { content: [{ type: "text", text: `Created report channel: ${result.channelId}` }] };
|
|
},
|
|
});
|
|
|
|
// ───────────────────────────────────────────────
|
|
// create-work-channel
|
|
// ───────────────────────────────────────────────
|
|
api.registerTool({
|
|
name: "create-work-channel",
|
|
description: "Create a new private Discord workspace channel with mode=work (turn-manager disabled, mode locked).",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
guildId: { type: "string", description: "Guild ID" },
|
|
name: { type: "string", description: "Channel name" },
|
|
members: { type: "array", items: { type: "string" }, description: "Additional Discord user IDs to add" },
|
|
},
|
|
required: ["guildId", "name"],
|
|
},
|
|
handler: async (params, ctx) => {
|
|
const p = params as { guildId: string; name: string; members?: string[] };
|
|
// Include calling agent's Discord ID if known
|
|
const callerDiscordId = ctx?.agentId ? identityRegistry.findByAgentId(ctx.agentId)?.discordUserId : undefined;
|
|
const members = [...(p.members ?? [])];
|
|
if (callerDiscordId && !members.includes(callerDiscordId)) members.push(callerDiscordId);
|
|
|
|
const result = await createManagedChannel({
|
|
guildId: p.guildId, name: p.name,
|
|
memberDiscordIds: members,
|
|
mode: "work",
|
|
callerCtx: { agentId: ctx?.agentId },
|
|
});
|
|
if (!result.ok) return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
|
|
return { content: [{ type: "text", text: `Created work channel: ${result.channelId}` }] };
|
|
},
|
|
});
|
|
|
|
// ───────────────────────────────────────────────
|
|
// create-discussion-channel
|
|
// ───────────────────────────────────────────────
|
|
api.registerTool({
|
|
name: "create-discussion-channel",
|
|
description: "Create a structured discussion channel between agents. The calling agent becomes the initiator.",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
callbackGuildId: { type: "string", description: "Guild ID of your current channel (for callback after discussion)" },
|
|
callbackChannelId: { type: "string", description: "Channel ID to post the summary to after discussion completes" },
|
|
name: { type: "string", description: "Discussion channel name" },
|
|
discussionGuide: { type: "string", description: "Topic, goals, and completion criteria for the discussion" },
|
|
participants: { type: "array", items: { type: "string" }, description: "Discord user IDs of participating agents" },
|
|
},
|
|
required: ["callbackGuildId", "callbackChannelId", "name", "discussionGuide", "participants"],
|
|
},
|
|
handler: async (params, ctx) => {
|
|
const p = params as {
|
|
callbackGuildId: string;
|
|
callbackChannelId: string;
|
|
name: string;
|
|
discussionGuide: string;
|
|
participants: string[];
|
|
};
|
|
const initiatorAgentId = ctx?.agentId;
|
|
if (!initiatorAgentId) {
|
|
return { content: [{ type: "text", text: "Cannot resolve initiator agentId from session" }], isError: true };
|
|
}
|
|
if (!moderatorBotToken) {
|
|
return { content: [{ type: "text", text: "moderatorBotToken not configured" }], isError: true };
|
|
}
|
|
if (!onDiscussionCreate) {
|
|
return { content: [{ type: "text", text: "Discussion service not available" }], isError: true };
|
|
}
|
|
|
|
const botId = getBotUserIdFromToken(moderatorBotToken);
|
|
const initiatorDiscordId = identityRegistry.findByAgentId(initiatorAgentId)?.discordUserId;
|
|
const memberIds = [...new Set([
|
|
...(initiatorDiscordId ? [initiatorDiscordId] : []),
|
|
...p.participants,
|
|
...(botId ? [botId] : []),
|
|
])];
|
|
|
|
const overwrites: Array<{ id: string; type: number; allow?: string; deny?: string }> = [
|
|
{ id: p.callbackGuildId, type: 0, deny: "1024" },
|
|
...memberIds.map((id) => ({ id, type: 1, allow: "1024" })),
|
|
];
|
|
|
|
let channelId: string;
|
|
try {
|
|
channelId = await createDiscordChannel({
|
|
token: moderatorBotToken,
|
|
guildId: p.callbackGuildId,
|
|
name: p.name,
|
|
permissionOverwrites: overwrites,
|
|
logger: api.logger,
|
|
});
|
|
} catch (err) {
|
|
return { content: [{ type: "text", text: `Failed to create channel: ${String(err)}` }], isError: true };
|
|
}
|
|
|
|
try {
|
|
channelStore.setLockedMode(channelId, "discussion", {
|
|
initiatorAgentId,
|
|
callbackGuildId: p.callbackGuildId,
|
|
callbackChannelId: p.callbackChannelId,
|
|
concluded: false,
|
|
});
|
|
} catch (err) {
|
|
return { content: [{ type: "text", text: `Failed to register channel: ${String(err)}` }], isError: true };
|
|
}
|
|
|
|
await onDiscussionCreate({
|
|
channelId,
|
|
guildId: p.callbackGuildId,
|
|
initiatorAgentId,
|
|
callbackGuildId: p.callbackGuildId,
|
|
callbackChannelId: p.callbackChannelId,
|
|
discussionGuide: p.discussionGuide,
|
|
participants: p.participants,
|
|
});
|
|
|
|
return { content: [{ type: "text", text: `Discussion channel created: ${channelId}` }] };
|
|
},
|
|
});
|
|
|
|
// ───────────────────────────────────────────────
|
|
// discussion-complete
|
|
// ───────────────────────────────────────────────
|
|
api.registerTool({
|
|
name: "discussion-complete",
|
|
description: "Mark a discussion as complete, archive the channel, and post the summary path to the callback channel.",
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
discussionChannelId: { type: "string", description: "The discussion channel ID" },
|
|
summary: { type: "string", description: "File path to the summary (must be under {workspace}/discussion-summary/)" },
|
|
},
|
|
required: ["discussionChannelId", "summary"],
|
|
},
|
|
handler: async (params, ctx) => {
|
|
const p = params as { discussionChannelId: string; summary: string };
|
|
const callerAgentId = ctx?.agentId;
|
|
if (!callerAgentId) {
|
|
return { content: [{ type: "text", text: "Cannot resolve agentId from session" }], isError: true };
|
|
}
|
|
|
|
const rec = channelStore.getRecord(p.discussionChannelId);
|
|
if (rec.mode !== "discussion") {
|
|
return { content: [{ type: "text", text: `Channel ${p.discussionChannelId} is not a discussion channel` }], isError: true };
|
|
}
|
|
if (!rec.discussion) {
|
|
return { content: [{ type: "text", text: "Discussion metadata not found" }], isError: true };
|
|
}
|
|
if (rec.discussion.initiatorAgentId !== callerAgentId) {
|
|
return {
|
|
content: [{ type: "text", text: `Only the initiator (${rec.discussion.initiatorAgentId}) may call discussion-complete` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
if (!p.summary.includes("discussion-summary")) {
|
|
return {
|
|
content: [{ type: "text", text: "Summary path must be under {workspace}/discussion-summary/" }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
channelStore.concludeDiscussion(p.discussionChannelId);
|
|
|
|
if (moderatorBotToken) {
|
|
const { sendModeratorMessage } = await import("../core/moderator-discord.js");
|
|
await sendModeratorMessage(
|
|
moderatorBotToken, rec.discussion.callbackChannelId,
|
|
`Discussion complete. Summary: ${p.summary}`,
|
|
api.logger,
|
|
).catch(() => undefined);
|
|
}
|
|
|
|
return { content: [{ type: "text", text: `Discussion ${p.discussionChannelId} concluded. Summary posted to ${rec.discussion.callbackChannelId}.` }] };
|
|
},
|
|
});
|
|
}
|