feat: add internal hooks system
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { shouldHandleTextCommands } from "../commands-registry.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { routeReply } from "./route-reply.js";
|
||||
import { handleBashCommand } from "./commands-bash.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
import { handleConfigCommand, handleDebugCommand } from "./commands-config.js";
|
||||
@@ -42,9 +44,8 @@ const HANDLERS: CommandHandler[] = [
|
||||
];
|
||||
|
||||
export async function handleCommands(params: HandleCommandsParams): Promise<CommandHandlerResult> {
|
||||
const resetRequested =
|
||||
params.command.commandBodyNormalized === "/reset" ||
|
||||
params.command.commandBodyNormalized === "/new";
|
||||
const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/);
|
||||
const resetRequested = Boolean(resetMatch);
|
||||
if (resetRequested && !params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /reset from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -52,6 +53,45 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
// Trigger internal hook for reset/new commands
|
||||
if (resetRequested && params.command.isAuthorizedSender) {
|
||||
const commandAction = resetMatch?.[1] ?? "new";
|
||||
const hookEvent = createInternalHookEvent(
|
||||
'command',
|
||||
commandAction,
|
||||
params.sessionKey ?? '',
|
||||
{
|
||||
sessionEntry: params.sessionEntry,
|
||||
previousSessionEntry: params.previousSessionEntry,
|
||||
commandSource: params.command.surface,
|
||||
senderId: params.command.senderId,
|
||||
cfg: params.cfg, // Pass config for LLM slug generation
|
||||
}
|
||||
);
|
||||
await triggerInternalHook(hookEvent);
|
||||
|
||||
// Send hook messages immediately if present
|
||||
if (hookEvent.messages.length > 0) {
|
||||
// Use OriginatingChannel/To if available, otherwise fall back to command channel/from
|
||||
const channel = params.ctx.OriginatingChannel || (params.command.channel as any);
|
||||
// For replies, use 'from' (the sender) not 'to' (which might be the bot itself)
|
||||
const to = params.ctx.OriginatingTo || params.command.from || params.command.to;
|
||||
|
||||
if (channel && to) {
|
||||
const hookReply = { text: hookEvent.messages.join('\n\n') };
|
||||
await routeReply({
|
||||
payload: hookReply,
|
||||
channel: channel,
|
||||
to: to,
|
||||
sessionKey: params.sessionKey,
|
||||
accountId: params.ctx.AccountId,
|
||||
threadId: params.ctx.MessageThreadId,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg: params.cfg,
|
||||
surface: params.command.surface,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { updateSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
setAbortMemory,
|
||||
stopSubagentsForRequester,
|
||||
} from "./abort.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
|
||||
function resolveSessionEntryForKey(
|
||||
store: Record<string, SessionEntry> | undefined,
|
||||
@@ -213,14 +214,27 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
}
|
||||
|
||||
// Trigger internal hook for stop command
|
||||
const hookEvent = createInternalHookEvent(
|
||||
'command',
|
||||
'stop',
|
||||
abortTarget.key ?? params.sessionKey ?? '',
|
||||
{
|
||||
sessionEntry: abortTarget.entry ?? params.sessionEntry,
|
||||
sessionId: abortTarget.sessionId,
|
||||
commandSource: params.command.surface,
|
||||
senderId: params.command.senderId,
|
||||
}
|
||||
);
|
||||
await triggerInternalHook(hookEvent);
|
||||
|
||||
const { stopped } = stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: abortTarget.key ?? params.sessionKey,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: formatAbortReplyText(stopped) },
|
||||
};
|
||||
|
||||
return { shouldContinue: false, reply: { text: formatAbortReplyText(stopped) } };
|
||||
};
|
||||
|
||||
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
|
||||
@@ -235,12 +249,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
|
||||
if (abortTarget.sessionId) {
|
||||
abortEmbeddedPiRun(abortTarget.sessionId);
|
||||
}
|
||||
const cleared = clearSessionQueues([abortTarget.key, abortTarget.sessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`stop-trigger: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
if (abortTarget.entry && params.sessionStore && abortTarget.key) {
|
||||
abortTarget.entry.abortedLastRun = true;
|
||||
abortTarget.entry.updatedAt = Date.now();
|
||||
@@ -253,12 +261,5 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman
|
||||
} else if (params.command.abortKey) {
|
||||
setAbortMemory(params.command.abortKey, true);
|
||||
}
|
||||
const { stopped } = stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: abortTarget.key ?? params.sessionKey,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: formatAbortReplyText(stopped) },
|
||||
};
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export type HandleCommandsParams = {
|
||||
failures: Array<{ gate: string; key: string }>;
|
||||
};
|
||||
sessionEntry?: SessionEntry;
|
||||
previousSessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
@@ -143,6 +144,24 @@ describe("handleCommands identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands internal hooks", () => {
|
||||
it("triggers hooks for /new with arguments", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/new take notes", cfg);
|
||||
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
|
||||
|
||||
await handleCommands(params);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "command", action: "new" }),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands context", () => {
|
||||
it("returns context help for /context", async () => {
|
||||
const cfg = {
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function handleInlineActions(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
previousSessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
@@ -66,8 +67,9 @@ export async function handleInlineActions(params: {
|
||||
sessionCtx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
@@ -203,6 +205,7 @@ export async function handleInlineActions(params: {
|
||||
failures: elevatedFailures,
|
||||
},
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
@@ -265,6 +268,7 @@ export async function handleInlineActions(params: {
|
||||
failures: elevatedFailures,
|
||||
},
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function getReplyFromConfig(
|
||||
let {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
@@ -122,6 +123,7 @@ export async function getReplyFromConfig(
|
||||
agentCfg,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
export type SessionInitResult = {
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry: SessionEntry;
|
||||
previousSessionEntry?: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
@@ -115,6 +116,7 @@ export async function initSessionState(params: {
|
||||
let bodyStripped: string | undefined;
|
||||
let systemSent = false;
|
||||
let abortedLastRun = false;
|
||||
let resetTriggered = false;
|
||||
|
||||
let persistedThinking: string | undefined;
|
||||
let persistedVerbose: string | undefined;
|
||||
@@ -149,12 +151,14 @@ export async function initSessionState(params: {
|
||||
if (trimmedBody === trigger || strippedForReset === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
resetTriggered = true;
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
|
||||
isNewSession = true;
|
||||
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
|
||||
resetTriggered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -168,6 +172,7 @@ export async function initSessionState(params: {
|
||||
}
|
||||
}
|
||||
const entry = sessionStore[sessionKey];
|
||||
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
|
||||
@@ -308,6 +313,7 @@ export async function initSessionState(params: {
|
||||
return {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
previousSessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
|
||||
@@ -75,6 +75,11 @@ export type MsgContext = {
|
||||
* The chat/channel/user ID where the reply should be sent.
|
||||
*/
|
||||
OriginatingTo?: string;
|
||||
/**
|
||||
* Messages from internal hooks to be included in the response.
|
||||
* Used for hook confirmation messages like "Session context saved to memory".
|
||||
*/
|
||||
HookMessages?: string[];
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
|
||||
Reference in New Issue
Block a user