feat: add prompt based hooks (#795)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
jnjpng
2026-02-05 17:55:00 -08:00
committed by GitHub
parent bbe02e90e8
commit ee28095ebc
11 changed files with 967 additions and 42 deletions

View File

@@ -12,6 +12,8 @@ import {
mergeHooksConfigs,
} from "../../hooks/loader";
import {
type CommandHookConfig,
type HookCommand,
type HookEvent,
type HooksConfig,
isToolEvent,
@@ -20,6 +22,16 @@ import {
} from "../../hooks/types";
import { settingsManager } from "../../settings-manager";
// Type-safe helper to extract command from a hook (tests only use command hooks)
function asCommand(
hook: HookCommand | undefined,
): CommandHookConfig | undefined {
if (hook && hook.type === "command") {
return hook as CommandHookConfig;
}
return undefined;
}
describe("Hooks Loader", () => {
let tempDir: string;
let fakeHome: string;
@@ -215,11 +227,11 @@ describe("Hooks Loader", () => {
const bashHooks = getMatchingHooks(config, "PreToolUse", "Bash");
expect(bashHooks).toHaveLength(1);
expect(bashHooks[0]?.command).toBe("bash hook");
expect(asCommand(bashHooks[0])?.command).toBe("bash hook");
const editHooks = getMatchingHooks(config, "PreToolUse", "Edit");
expect(editHooks).toHaveLength(1);
expect(editHooks[0]?.command).toBe("edit hook");
expect(asCommand(editHooks[0])?.command).toBe("edit hook");
});
test("returns wildcard hooks for any tool", () => {
@@ -234,7 +246,7 @@ describe("Hooks Loader", () => {
const hooks = getMatchingHooks(config, "PreToolUse", "AnyTool");
expect(hooks).toHaveLength(1);
expect(hooks[0]?.command).toBe("all tools hook");
expect(asCommand(hooks[0])?.command).toBe("all tools hook");
});
test("returns multiple matching hooks", () => {
@@ -315,9 +327,9 @@ describe("Hooks Loader", () => {
const hooks = getMatchingHooks(config, "PreToolUse", "Bash");
expect(hooks).toHaveLength(3);
expect(hooks[0]?.command).toBe("multi tool");
expect(hooks[1]?.command).toBe("bash specific");
expect(hooks[2]?.command).toBe("wildcard");
expect(asCommand(hooks[0])?.command).toBe("multi tool");
expect(asCommand(hooks[1])?.command).toBe("bash specific");
expect(asCommand(hooks[2])?.command).toBe("wildcard");
});
});
@@ -496,7 +508,9 @@ describe("Hooks Loader", () => {
const hooks = await loadProjectLocalHooks(tempDir);
expect(hooks.PreToolUse).toHaveLength(1);
expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("echo local");
expect(asCommand(hooks.PreToolUse?.[0]?.hooks[0])?.command).toBe(
"echo local",
);
});
});
@@ -517,8 +531,12 @@ describe("Hooks Loader", () => {
const merged = mergeHooksConfigs(global, project, projectLocal);
expect(merged.PreToolUse).toHaveLength(2);
expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); // Local first
expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("project"); // Project second
expect(asCommand(merged.PreToolUse?.[0]?.hooks[0])?.command).toBe(
"local",
); // Local first
expect(asCommand(merged.PreToolUse?.[1]?.hooks[0])?.command).toBe(
"project",
); // Project second
});
test("project-local hooks run before global hooks", () => {
@@ -537,8 +555,12 @@ describe("Hooks Loader", () => {
const merged = mergeHooksConfigs(global, project, projectLocal);
expect(merged.PreToolUse).toHaveLength(2);
expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); // Local first
expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("global"); // Global last
expect(asCommand(merged.PreToolUse?.[0]?.hooks[0])?.command).toBe(
"local",
); // Local first
expect(asCommand(merged.PreToolUse?.[1]?.hooks[0])?.command).toBe(
"global",
); // Global last
});
test("all three levels merge correctly", () => {
@@ -577,9 +599,15 @@ describe("Hooks Loader", () => {
// PreToolUse: local -> project -> global
expect(merged.PreToolUse).toHaveLength(3);
expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local");
expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("project");
expect(merged.PreToolUse?.[2]?.hooks[0]?.command).toBe("global");
expect(asCommand(merged.PreToolUse?.[0]?.hooks[0])?.command).toBe(
"local",
);
expect(asCommand(merged.PreToolUse?.[1]?.hooks[0])?.command).toBe(
"project",
);
expect(asCommand(merged.PreToolUse?.[2]?.hooks[0])?.command).toBe(
"global",
);
// Others only have one source
expect(merged.PostToolUse).toHaveLength(1);
@@ -624,8 +652,10 @@ describe("Hooks Loader", () => {
// Local should come before project
expect(hooks.PreToolUse).toHaveLength(2);
expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("local");
expect(hooks.PreToolUse?.[1]?.hooks[0]?.command).toBe("project");
expect(asCommand(hooks.PreToolUse?.[0]?.hooks[0])?.command).toBe("local");
expect(asCommand(hooks.PreToolUse?.[1]?.hooks[0])?.command).toBe(
"project",
);
});
test("handles missing local settings gracefully", async () => {
@@ -650,7 +680,9 @@ describe("Hooks Loader", () => {
const hooks = await loadHooks(tempDir);
expect(hooks.PreToolUse).toHaveLength(1);
expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("project");
expect(asCommand(hooks.PreToolUse?.[0]?.hooks[0])?.command).toBe(
"project",
);
});
});
});

View File

@@ -0,0 +1,425 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { executePromptHook } from "../../hooks/prompt-executor";
import {
HookExitCode,
type PreToolUseHookInput,
type StopHookInput,
} from "../../hooks/types";
interface GenerateOpts {
body: {
prompt: string;
system_prompt: string;
override_model?: string;
response_schema?: Record<string, unknown>;
};
timeout?: number;
}
// Mock getClient to avoid real API calls
const mockPost = mock(
(_path: string, _opts: GenerateOpts) =>
Promise.resolve({
content: '{"ok": true}',
model: "test-model",
usage: {
completion_tokens: 10,
prompt_tokens: 50,
total_tokens: 60,
},
}) as Promise<Record<string, unknown>>,
);
const mockGetClient = mock(() => Promise.resolve({ post: mockPost }));
// Mock getCurrentAgentId
const mockGetCurrentAgentId = mock(() => "agent-test-123");
mock.module("../../agent/client", () => ({
getClient: mockGetClient,
}));
mock.module("../../agent/context", () => ({
getCurrentAgentId: mockGetCurrentAgentId,
}));
/** Helper to get the first call's [path, opts] from mockPost */
function firstPostCall(): [string, GenerateOpts] {
const calls = mockPost.mock.calls;
const call = calls[0];
if (!call) throw new Error("mockPost was not called");
return call;
}
describe("Prompt Hook Executor", () => {
beforeEach(() => {
mockPost.mockClear();
mockGetClient.mockClear();
mockGetCurrentAgentId.mockClear();
// Default: allow
mockPost.mockResolvedValue({
content: '{"ok": true}',
model: "anthropic/claude-3-5-haiku-20241022",
usage: {
completion_tokens: 10,
prompt_tokens: 50,
total_tokens: 60,
},
});
});
afterEach(() => {
// Clean up env vars
delete process.env.LETTA_AGENT_ID;
});
describe("executePromptHook", () => {
test("calls generate endpoint and returns ALLOW when ok is true", async () => {
const hook = {
type: "prompt" as const,
prompt: "Check if this tool call is safe",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.ALLOW);
expect(result.timedOut).toBe(false);
expect(mockGetClient).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledTimes(1);
// Verify the correct path and body were sent
const [path, opts] = firstPostCall();
expect(path).toBe("/v1/agents/agent-abc-123/generate");
expect(opts.body.prompt).toContain("Check if this tool call is safe");
expect(opts.body.system_prompt).toBeTruthy();
expect(opts.body.override_model).toBeUndefined();
expect(opts.body.response_schema).toBeDefined();
const schema = opts.body.response_schema as {
properties: { ok: { type: string } };
};
expect(schema.properties.ok.type).toBe("boolean");
});
test("returns BLOCK when ok is false", async () => {
mockPost.mockResolvedValue({
content: '{"ok": false, "reason": "Dangerous command detected"}',
model: "anthropic/claude-3-5-haiku-20241022",
usage: {
completion_tokens: 15,
prompt_tokens: 50,
total_tokens: 65,
},
});
const hook = {
type: "prompt" as const,
prompt: "Block dangerous commands",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "rm -rf /" },
agent_id: "agent-abc-123",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.BLOCK);
expect(result.stderr).toBe("Dangerous command detected");
});
test("uses custom model when specified in hook config", async () => {
const hook = {
type: "prompt" as const,
prompt: "Evaluate this action",
model: "openai/gpt-4o",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Edit",
tool_input: { file_path: "/etc/passwd" },
agent_id: "agent-abc-123",
};
await executePromptHook(hook, input);
const [, opts] = firstPostCall();
expect(opts.body.override_model).toBe("openai/gpt-4o");
});
test("uses custom timeout when specified", async () => {
const hook = {
type: "prompt" as const,
prompt: "Evaluate this action",
timeout: 5000,
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "echo hi" },
agent_id: "agent-abc-123",
};
await executePromptHook(hook, input);
const [, opts] = firstPostCall();
expect(opts.timeout).toBe(5000);
});
test("replaces $ARGUMENTS placeholder in prompt", async () => {
const hook = {
type: "prompt" as const,
prompt: 'Check if tool "$ARGUMENTS" is safe to run',
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
await executePromptHook(hook, input);
const [, opts] = firstPostCall();
// $ARGUMENTS should have been replaced with JSON
expect(opts.body.prompt).not.toContain("$ARGUMENTS");
expect(opts.body.prompt).toContain('"event_type": "PreToolUse"');
expect(opts.body.prompt).toContain('"tool_name": "Bash"');
});
test("appends hook input when $ARGUMENTS is not in prompt", async () => {
const hook = {
type: "prompt" as const,
prompt: "Is this tool call safe?",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
await executePromptHook(hook, input);
const [, opts] = firstPostCall();
expect(opts.body.prompt).toContain("Is this tool call safe?");
expect(opts.body.prompt).toContain("Hook input:");
expect(opts.body.prompt).toContain('"tool_name": "Bash"');
});
test("falls back to getCurrentAgentId when input has no agent_id", async () => {
mockGetCurrentAgentId.mockReturnValue("agent-from-context");
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: StopHookInput = {
event_type: "Stop",
working_directory: "/tmp",
stop_reason: "end_turn",
};
await executePromptHook(hook, input);
const [path] = firstPostCall();
expect(path).toBe("/v1/agents/agent-from-context/generate");
});
test("falls back to LETTA_AGENT_ID env var when context unavailable", async () => {
mockGetCurrentAgentId.mockImplementation(() => {
throw new Error("No agent context set");
});
process.env.LETTA_AGENT_ID = "agent-from-env";
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: StopHookInput = {
event_type: "Stop",
working_directory: "/tmp",
stop_reason: "end_turn",
};
await executePromptHook(hook, input);
const [path] = firstPostCall();
expect(path).toBe("/v1/agents/agent-from-env/generate");
});
test("returns ERROR when no agent_id available", async () => {
mockGetCurrentAgentId.mockImplementation(() => {
throw new Error("No agent context set");
});
delete process.env.LETTA_AGENT_ID;
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: StopHookInput = {
event_type: "Stop",
working_directory: "/tmp",
stop_reason: "end_turn",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.ERROR);
expect(result.error).toContain("agent_id");
expect(mockPost).not.toHaveBeenCalled();
});
test("returns ERROR when API call fails", async () => {
mockPost.mockRejectedValue(new Error("Network error"));
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.ERROR);
expect(result.error).toContain("Network error");
});
test("returns ERROR when LLM returns unparseable response", async () => {
mockPost.mockResolvedValue({
content: "This is not valid JSON at all",
model: "anthropic/claude-3-5-haiku-20241022",
usage: {
completion_tokens: 10,
prompt_tokens: 50,
total_tokens: 60,
},
});
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.ERROR);
expect(result.error).toContain("Failed to parse");
});
test("handles JSON wrapped in markdown code blocks", async () => {
mockPost.mockResolvedValue({
content: '```json\n{"ok": true}\n```',
model: "anthropic/claude-3-5-haiku-20241022",
usage: {
completion_tokens: 10,
prompt_tokens: 50,
total_tokens: 60,
},
});
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.ALLOW);
});
test("returns ERROR when ok is not a boolean", async () => {
mockPost.mockResolvedValue({
content: '{"ok": "yes", "reason": "looks fine"}',
model: "anthropic/claude-3-5-haiku-20241022",
usage: {
completion_tokens: 10,
prompt_tokens: 50,
total_tokens: 60,
},
});
const hook = {
type: "prompt" as const,
prompt: "Check this",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
const result = await executePromptHook(hook, input);
expect(result.exitCode).toBe(HookExitCode.ERROR);
expect(result.error).toContain('"ok" must be a boolean');
});
test("sends response_schema for structured output", async () => {
const hook = {
type: "prompt" as const,
prompt: "Is this safe?",
};
const input: PreToolUseHookInput = {
event_type: "PreToolUse",
working_directory: "/tmp",
tool_name: "Bash",
tool_input: { command: "ls" },
agent_id: "agent-abc-123",
};
await executePromptHook(hook, input);
const [, opts] = firstPostCall();
expect(opts.body.response_schema).toEqual({
properties: {
ok: {
type: "boolean",
description: "true to allow the action, false to block it",
},
reason: {
type: "string",
description:
"Explanation for the decision. Required when ok is false.",
},
},
required: ["ok"],
});
});
});
});

View File

@@ -2,7 +2,19 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CommandHookConfig, HookCommand } from "../hooks/types";
import { settingsManager } from "../settings-manager";
// Type-safe helper to extract command from a hook (tests only use command hooks)
function asCommand(
hook: HookCommand | undefined,
): CommandHookConfig | undefined {
if (hook && hook.type === "command") {
return hook as CommandHookConfig;
}
return undefined;
}
import {
deleteSecureTokens,
isKeychainAvailable,
@@ -558,7 +570,7 @@ describe("Settings Manager - Hooks", () => {
const settings = settingsManager.getSettings();
expect(settings.hooks?.PreToolUse).toHaveLength(1);
expect(settings.hooks?.PreToolUse?.[0]?.hooks[0]?.command).toBe(
expect(asCommand(settings.hooks?.PreToolUse?.[0]?.hooks[0])?.command).toBe(
"echo persisted",
);
expect(settings.hooks?.SessionStart).toHaveLength(1);
@@ -631,7 +643,9 @@ describe("Settings Manager - Hooks", () => {
expect(reloaded.hooks?.Stop).toHaveLength(1);
// Simple event hooks are in SimpleHookMatcher format with hooks array
expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook");
expect(asCommand(reloaded.hooks?.Stop?.[0]?.hooks[0])?.command).toBe(
"echo stop-hook",
);
});
test("All 11 hook event types can be configured", async () => {