feat(plugins): add before_dispatch hook for pre-LLM message interception

Add a new plugin hook `before_dispatch` that fires in
`dispatchReplyFromConfig` after `message_received` hooks and before
LLM invocation. This allows plugins to block the entire dispatch
and optionally send a direct reply to the user.

Use case: security plugins (e.g. authentication gates) can prevent
unauthenticated sessions from consuming LLM tokens by blocking the
dispatch early and returning a "please login" message.

Refs: #43418
Made-with: Cursor
This commit is contained in:
李玉涵 2026-03-12 03:22:09 +08:00
parent 01ffc5db24
commit e84a11411a
3 changed files with 104 additions and 0 deletions

View File

@ -208,6 +208,34 @@ export async function dispatchReplyFromConfig(params: {
);
}
// Run before_dispatch plugin hooks (blocking — can abort dispatch before LLM).
// This lets security plugins (e.g. authentication gates) prevent LLM invocation
// entirely for unauthenticated sessions, saving tokens and enforcing access.
if (hookRunner?.hasHooks("before_dispatch") && sessionKey) {
const beforeDispatchResult = await hookRunner.runBeforeDispatch(
{
sessionKey,
channelId: channel,
senderId: ctx.SenderId,
conversationId: hookContext.conversationId,
isGroup,
content: hookContext.content,
messageId: messageIdForHook,
},
toPluginMessageContext(hookContext),
);
if (beforeDispatchResult?.block) {
if (beforeDispatchResult.replyText) {
dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText });
}
recordProcessed("skipped", { reason: "before_dispatch_blocked" });
return {
queuedFinal: Boolean(beforeDispatchResult.replyText),
counts: dispatcher.getQueuedCounts(),
};
}
}
// Check if we should route replies to originating channel instead of dispatcher.
// Only route when the originating channel is DIFFERENT from the current surface.
// This handles cross-provider routing (e.g., message from Telegram being processed

View File

@ -50,6 +50,8 @@ import type {
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
PluginHookBeforeDispatchEvent,
PluginHookBeforeDispatchResult,
} from "./types.js";
// Re-export types for consumers
@ -81,6 +83,8 @@ export type {
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
PluginHookBeforeDispatchEvent,
PluginHookBeforeDispatchResult,
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
@ -426,6 +430,50 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return runVoidHook("message_sent", event, ctx);
}
// =========================================================================
// Dispatch Hooks
// =========================================================================
/**
* Run before_dispatch hook.
* Allows plugins to block the entire message dispatch before LLM invocation
* and optionally send a reply text directly to the user.
* Runs sequentially first handler that returns { block: true } wins.
*/
async function runBeforeDispatch(
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookMessageContext,
): Promise<PluginHookBeforeDispatchResult | undefined> {
const hooks = getHooksForName(registry, "before_dispatch");
if (hooks.length === 0) {
return undefined;
}
logger?.debug?.(`[hooks] running before_dispatch (${hooks.length} handlers, sequential)`);
for (const hook of hooks) {
try {
const result = await (
hook.handler as (
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookMessageContext,
) =>
| Promise<PluginHookBeforeDispatchResult | void>
| PluginHookBeforeDispatchResult
| void
)(event, ctx);
if (result?.block) {
return result;
}
} catch (err) {
handleHookError({ hookName: "before_dispatch", pluginId: hook.pluginId, error: err });
}
}
return undefined;
}
// =========================================================================
// Tool Hooks
// =========================================================================
@ -737,6 +785,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runMessageReceived,
runMessageSending,
runMessageSent,
// Dispatch hooks
runBeforeDispatch,
// Tool hooks
runBeforeToolCall,
runAfterToolCall,

View File

@ -341,6 +341,7 @@ export type PluginHookName =
| "subagent_delivery_target"
| "subagent_spawned"
| "subagent_ended"
| "before_dispatch"
| "gateway_start"
| "gateway_stop";
@ -367,6 +368,7 @@ export const PLUGIN_HOOK_NAMES = [
"subagent_delivery_target",
"subagent_spawned",
"subagent_ended",
"before_dispatch",
"gateway_start",
"gateway_stop",
] as const satisfies readonly PluginHookName[];
@ -668,6 +670,26 @@ export type PluginHookBeforeMessageWriteResult = {
message?: AgentMessage; // Optional: modified message to write instead
};
// before_dispatch hook — fired in dispatchReplyFromConfig before LLM invocation.
// Allows plugins to block the entire dispatch (preventing LLM processing) and
// optionally send a direct reply to the user.
export type PluginHookBeforeDispatchEvent = {
sessionKey: string;
channelId: string;
senderId?: string;
conversationId?: string;
isGroup: boolean;
content: string;
messageId?: string;
};
export type PluginHookBeforeDispatchResult = {
/** If true, the dispatch is aborted — no LLM invocation occurs. */
block?: boolean;
/** If block is true, this text is sent as a direct reply to the user. */
replyText?: string;
};
// Session context
export type PluginHookSessionContext = {
agentId?: string;
@ -873,6 +895,10 @@ export type PluginHookHandlerMap = {
event: PluginHookSubagentEndedEvent,
ctx: PluginHookSubagentContext,
) => Promise<void> | void;
before_dispatch: (
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookMessageContext,
) => Promise<PluginHookBeforeDispatchResult | void> | PluginHookBeforeDispatchResult | void;
gateway_start: (
event: PluginHookGatewayStartEvent,
ctx: PluginHookGatewayContext,