refactor: new design — sidecar services, moderator Gateway client, tool execute API

- Replace standalone no-reply-api Docker service with unified sidecar (services/main.mjs)
  that routes /no-reply/* and /moderator/* and starts/stops with openclaw-gateway
- Add moderator Discord Gateway client (services/moderator/index.mjs) for real-time
  MESSAGE_CREATE push instead of polling; notifies plugin via HTTP callback
- Add plugin HTTP routes (plugin/web/dirigent-api.ts) for moderator → plugin callbacks
  (wake-from-dormant, interrupt tail-match)
- Fix tool registration format: AgentTool requires execute: not handler:; factory form
  for tools needing ctx
- Rename no-reply-process.ts → sidecar-process.ts, startNoReplyApi → startSideCar
- Remove dead config fields from openclaw.plugin.json (humanList, agentList, listMode,
  channelPoliciesFile, endSymbols, waitIdentifier, multiMessage*, bypassUserIds, etc.)
- Rename noReplyPort → sideCarPort
- Remove docker-compose.yml, dev-up/down scripts, package-plugin.mjs, test-no-reply-api.mjs
- Update install.mjs: clean dist before build, copy services/, drop dead config writes
- Update README, Makefile, smoke script for new architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-10 08:07:59 +01:00
parent d8ac9ee0f9
commit 32dc9a4233
28 changed files with 1310 additions and 900 deletions

View File

@@ -176,8 +176,6 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
return;
}
clearTurnPending(channelId, agentId);
api.logger.info(
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
);
@@ -186,6 +184,10 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
// Real turn: wait for Discord delivery before triggering next speaker.
// Anchor was set in before_model_resolve just before the LLM call, so any
// message from the agent after the anchor must be from this turn.
// NOTE: clearTurnPending is intentionally deferred until after pollForTailMatch
// returns. While waiting, isTurnPending remains true so that any re-trigger of
// this agent is correctly treated as a self-wakeup (suppressed), preventing it
// from starting a second real turn during the tail-match window.
const identity = identityRegistry.findByAgentId(agentId);
if (identity && moderatorBotToken) {
const anchorId = getAnchor(channelId, agentId) ?? "0";
@@ -202,6 +204,7 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
if (isDormant(channelId)) {
// Channel is dormant: a new external message woke it — restart from first speaker
api.logger.info(`dirigent: tail-match interrupted (dormant) channel=${channelId} — waking`);
clearTurnPending(channelId, agentId);
const first = wakeFromDormant(channelId);
if (first) await triggerNextSpeaker(channelId, first);
return;
@@ -243,6 +246,12 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
advancingChannels.delete(channelId);
}
// Clear turn pending AFTER advanceSpeaker completes. This ensures isTurnPending
// remains true during the async rebuildFn window at cycle boundaries, preventing
// re-triggers from starting a second real turn while currentIndex is still at the
// outgoing speaker's position.
clearTurnPending(channelId, agentId);
if (enteredDormant) {
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
if (mode === "discussion") {

View File

@@ -3,7 +3,7 @@ import type { ChannelStore } from "../core/channel-store.js";
import type { IdentityRegistry } from "../core/identity-registry.js";
import { parseDiscordChannelId } from "./before-model-resolve.js";
import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js";
import { sendAndDelete, sendModeratorMessage, userIdFromBotToken } from "../core/moderator-discord.js";
import { sendScheduleTrigger, userIdFromBotToken } from "../core/moderator-discord.js";
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
import type { InterruptFn } from "./agent-end.js";
@@ -14,24 +14,17 @@ type Deps = {
moderatorBotToken: string | undefined;
scheduleIdentifier: string;
interruptTailMatch: InterruptFn;
debugMode: boolean;
/**
* When true, the moderator service handles wake-from-dormant and
* interrupt-tail-match via HTTP callback. This hook only runs speaker-list
* initialization in that case.
*/
moderatorHandlesMessages?: boolean;
};
/**
* Process-level dedup for concluded-discussion auto-replies.
* Multiple agent VM contexts all fire message_received for the same incoming message;
* only the first should send the "This discussion is closed" reply.
* Keyed on channelId:messageId; evicted after 500 entries.
*/
const _CONCLUDED_REPLY_DEDUP_KEY = "_dirigentConcludedReplyDedup";
if (!(globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY]) {
(globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY] = new Set<string>();
}
const concludedReplyDedup: Set<string> = (globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY] as Set<string>;
export function registerMessageReceivedHook(deps: Deps): void {
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps;
// Derive the moderator bot's own Discord user ID so we can skip self-messages
// from waking dormant channels (idle reminders must not re-trigger the cycle).
const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch, debugMode, moderatorHandlesMessages } = deps;
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
api.on("message_received", async (event, ctx) => {
@@ -42,23 +35,19 @@ export function registerMessageReceivedHook(deps: Deps): void {
// Extract Discord channel ID from ctx or event metadata
let channelId: string | undefined;
// ctx.channelId may be bare "1234567890" or "channel:1234567890"
if (typeof c.channelId === "string") {
const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1];
if (bare) channelId = bare;
}
// fallback: sessionKey (per-session api instances)
if (!channelId && typeof c.sessionKey === "string") {
channelId = parseDiscordChannelId(c.sessionKey);
}
// fallback: metadata.to / originatingTo = "channel:1234567890"
if (!channelId) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const to = String(metadata?.to ?? metadata?.originatingTo ?? "");
const toMatch = to.match(/:(\d+)$/);
if (toMatch) channelId = toMatch[1];
}
// fallback: conversation_info
if (!channelId) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
@@ -69,45 +58,12 @@ export function registerMessageReceivedHook(deps: Deps): void {
const mode = channelStore.getMode(channelId);
// dead: suppress routing entirely (OpenClaw handles no-route automatically,
// but we handle archived auto-reply here)
if (mode === "report") return;
// archived: auto-reply via moderator (deduped — only one agent instance should reply)
if (mode === "discussion") {
const rec = channelStore.getRecord(channelId);
if (rec.discussion?.concluded && moderatorBotToken) {
const metadata = e.metadata as Record<string, unknown> | undefined;
const convInfo = metadata?.conversation_info as Record<string, unknown> | undefined;
const incomingMsgId = String(
convInfo?.message_id ??
metadata?.message_id ??
metadata?.messageId ??
e.id ?? "",
);
const dedupKey = `${channelId}:${incomingMsgId}`;
if (!concludedReplyDedup.has(dedupKey)) {
concludedReplyDedup.add(dedupKey);
if (concludedReplyDedup.size > 500) {
const oldest = concludedReplyDedup.values().next().value;
if (oldest) concludedReplyDedup.delete(oldest);
}
await sendModeratorMessage(
moderatorBotToken, channelId,
"This discussion is closed and no longer active.",
api.logger,
).catch(() => undefined);
}
return;
}
}
if (mode === "none" || mode === "work") return;
// chat / discussion (active): initialize speaker list on first message if needed
// ── Speaker-list initialization (always runs, even with moderator service) ──
const initializingChannels = getInitializingChannels();
if (!hasSpeakers(channelId) && moderatorBotToken) {
// Guard against concurrent initialization from multiple VM contexts
if (initializingChannels.has(channelId)) {
api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`);
return;
@@ -126,7 +82,7 @@ export function registerMessageReceivedHook(deps: Deps): void {
setSpeakerList(channelId, speakers);
const first = speakers[0];
api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`);
await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger);
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
return;
}
} finally {
@@ -134,8 +90,8 @@ export function registerMessageReceivedHook(deps: Deps): void {
}
}
// chat / discussion (active): check if this is an external message
// that should interrupt an in-progress tail-match or wake dormant
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
if (moderatorHandlesMessages) return;
const senderId = String(
(e.metadata as Record<string, unknown>)?.senderId ??
@@ -143,7 +99,6 @@ export function registerMessageReceivedHook(deps: Deps): void {
e.from ?? "",
);
// Identify the sender: is it the current speaker's Discord account?
const currentSpeakerIsThisSender = (() => {
if (!senderId) return false;
const entry = identityRegistry.findByDiscordUserId(senderId);
@@ -152,17 +107,16 @@ export function registerMessageReceivedHook(deps: Deps): void {
})();
if (!currentSpeakerIsThisSender) {
// Non-current-speaker posted — interrupt any tail-match in progress
interruptTailMatch(channelId);
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
if (senderId !== moderatorBotUserId) {
interruptTailMatch(channelId);
api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`);
}
// Wake from dormant if needed — but ignore the moderator bot's own messages
// (e.g. idle reminder) to prevent it from immediately re-waking the channel.
if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) {
const first = wakeFromDormant(channelId);
if (first) {
const msg = `<@${first.discordUserId}>${scheduleIdentifier}`;
await sendAndDelete(moderatorBotToken, channelId, msg, api.logger);
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
}
}