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

@@ -4,17 +4,35 @@
import { type ChildProcess, spawn } from "node:child_process";
import { buildShellLaunchers } from "../tools/impl/shellLaunchers";
import { executePromptHook } from "./prompt-executor";
import {
type CommandHookConfig,
type HookCommand,
type HookExecutionResult,
HookExitCode,
type HookInput,
type HookResult,
isCommandHook,
isPromptHook,
} from "./types";
/** Default timeout for hook execution (60 seconds) */
const DEFAULT_TIMEOUT_MS = 60000;
/**
* Get a display identifier for a hook (for logging and feedback)
*/
function getHookIdentifier(hook: HookCommand): string {
if (isCommandHook(hook)) {
return hook.command;
}
if (isPromptHook(hook)) {
// Use first 50 chars of prompt as identifier
return `prompt:${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
}
return "unknown";
}
/**
* Try to spawn a hook command with a specific launcher
* Returns the child process or throws an error
@@ -50,13 +68,45 @@ function trySpawnWithLauncher(
}
/**
* Execute a single hook command with JSON input via stdin
* Uses cross-platform shell launchers with fallback support
* Execute a single hook with JSON input
* Dispatches to appropriate executor based on hook type:
* - "command": executes shell command with JSON via stdin
* - "prompt": sends to LLM for evaluation
*/
export async function executeHookCommand(
hook: HookCommand,
input: HookInput,
workingDirectory: string = process.cwd(),
): Promise<HookResult> {
// Dispatch based on hook type
if (isPromptHook(hook)) {
return executePromptHook(hook, input, workingDirectory);
}
// Default to command hook execution
if (isCommandHook(hook)) {
return executeCommandHook(hook, input, workingDirectory);
}
// Unknown hook type
return {
exitCode: HookExitCode.ERROR,
stdout: "",
stderr: "",
timedOut: false,
durationMs: 0,
error: `Unknown hook type: ${(hook as HookCommand).type}`,
};
}
/**
* Execute a command hook with JSON input via stdin
* Uses cross-platform shell launchers with fallback support
*/
export async function executeCommandHook(
hook: CommandHookConfig,
input: HookInput,
workingDirectory: string = process.cwd(),
): Promise<HookResult> {
const startTime = Date.now();
const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS;
@@ -307,11 +357,10 @@ export async function executeHooks(
}
// Collect feedback from stderr when hook blocks
// Format: [command]: {stderr} per spec
if (result.exitCode === HookExitCode.BLOCK) {
blocked = true;
if (result.stderr) {
feedback.push(`[${hook.command}]: ${result.stderr}`);
feedback.push(`[${getHookIdentifier(hook)}]: ${result.stderr}`);
}
// Stop processing more hooks after a block
break;
@@ -358,7 +407,7 @@ export async function executeHooksParallel(
const hook = hooks[i];
if (!result || !hook) continue;
// For exit 0, try to parse JSON for additionalContext (matching Claude Code behavior)
// For exit 0, try to parse JSON for additionalContext
if (result.exitCode === HookExitCode.ALLOW && result.stdout?.trim()) {
try {
const json = JSON.parse(result.stdout.trim());
@@ -373,11 +422,11 @@ export async function executeHooksParallel(
}
}
// Format: [command]: {stderr} per spec
// Collect feedback from stderr when hook blocks
if (result.exitCode === HookExitCode.BLOCK) {
blocked = true;
if (result.stderr) {
feedback.push(`[${hook.command}]: ${result.stderr}`);
feedback.push(`[${getHookIdentifier(hook)}]: ${result.stderr}`);
}
}
if (result.exitCode === HookExitCode.ERROR) {

View File

@@ -266,6 +266,7 @@ export async function runStopHooks(
workingDirectory: string = process.cwd(),
precedingReasoning?: string,
assistantMessage?: string,
userMessage?: string,
): Promise<HookExecutionResult> {
const hooks = await getHooksForEvent("Stop", undefined, workingDirectory);
if (hooks.length === 0) {
@@ -280,6 +281,7 @@ export async function runStopHooks(
tool_call_count: toolCallCount,
preceding_reasoning: precedingReasoning,
assistant_message: assistantMessage,
user_message: userMessage,
};
// Run sequentially - Stop can block

View File

@@ -8,9 +8,11 @@ import {
type HookEvent,
type HookMatcher,
type HooksConfig,
isPromptHook,
isToolEvent,
type SimpleHookEvent,
type SimpleHookMatcher,
supportsPromptHooks,
type ToolHookEvent,
} from "./types";
@@ -177,6 +179,33 @@ export function matchesTool(pattern: string, toolName: string): boolean {
}
}
/**
* Filter hooks, removing prompt hooks from unsupported events with a warning
*/
function filterHooksForEvent(
hooks: HookCommand[],
event: HookEvent,
): HookCommand[] {
const filtered: HookCommand[] = [];
const promptHooksSupported = supportsPromptHooks(event);
for (const hook of hooks) {
if (isPromptHook(hook)) {
if (!promptHooksSupported) {
// Warn about unsupported prompt hook
console.warn(
`\x1b[33m[hooks] Warning: Prompt hooks are not supported for the ${event} event. ` +
`Ignoring prompt hook.\x1b[0m`,
);
continue;
}
}
filtered.push(hook);
}
return filtered;
}
/**
* Get all hooks that match a specific event and tool name
*/
@@ -200,7 +229,7 @@ export function getMatchingHooks(
hooks.push(...matcher.hooks);
}
}
return hooks;
return filterHooksForEvent(hooks, event);
} else {
// Simple events use SimpleHookMatcher[] - extract hooks from each matcher
const matchers = config[event as SimpleHookEvent] as
@@ -214,7 +243,7 @@ export function getMatchingHooks(
for (const matcher of matchers) {
hooks.push(...matcher.hooks);
}
return hooks;
return filterHooksForEvent(hooks, event);
}
}

View File

@@ -0,0 +1,260 @@
import { getClient } from "../agent/client";
import { getCurrentAgentId } from "../agent/context";
import {
HookExitCode,
type HookInput,
type HookResult,
PROMPT_ARGUMENTS_PLACEHOLDER,
type PromptHookConfig,
type PromptHookResponse,
} from "./types";
/** Default timeout for prompt hook execution (30 seconds) */
const DEFAULT_PROMPT_TIMEOUT_MS = 30000;
/**
* System prompt for the LLM to evaluate hooks.
* Instructs the model to return a JSON decision per Claude Code spec.
*/
const PROMPT_HOOK_SYSTEM = `You are a hook evaluator for a coding assistant. Your job is to evaluate whether an action should be allowed or blocked based on the provided context and criteria.
You will receive:
1. Hook input JSON containing context about the action (event type, tool info, etc.)
2. A user-defined prompt with evaluation criteria
You must respond with ONLY a valid JSON object (no markdown, no explanation) with the following fields:
- "ok": true to allow the action, false to prevent it
- "reason": Required when ok is false. Explanation for your decision.
Example responses:
- To allow: {"ok": true}
- To block: {"ok": false, "reason": "This action violates the security policy"}
Respond with JSON only. No markdown code blocks. No explanation outside the JSON.`;
/**
* Build the prompt to send to the LLM, replacing $ARGUMENTS with hook input.
* If $ARGUMENTS is not present in the prompt, append the input JSON.
*/
function buildPrompt(hookPrompt: string, input: HookInput): string {
const inputJson = JSON.stringify(input, null, 2);
// If $ARGUMENTS placeholder exists, replace all occurrences
if (hookPrompt.includes(PROMPT_ARGUMENTS_PLACEHOLDER)) {
return hookPrompt.replaceAll(PROMPT_ARGUMENTS_PLACEHOLDER, inputJson);
}
// Otherwise, append input JSON to the prompt
return `${hookPrompt}\n\nHook input:\n${inputJson}`;
}
/**
* Parse the LLM response as JSON, handling potential formatting issues
*/
function parsePromptResponse(response: string): PromptHookResponse {
// Try to extract JSON from the response
let jsonStr = response.trim();
// Handle markdown code blocks
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (jsonMatch) {
jsonStr = jsonMatch[1] || jsonStr;
}
// Try to find JSON object in the response (non-greedy to avoid spanning multiple objects)
const objectMatch = jsonStr.match(/\{[\s\S]*?\}/);
if (objectMatch) {
jsonStr = objectMatch[0];
}
try {
const parsed = JSON.parse(jsonStr);
// Validate the response structure - ok must be a boolean
if (typeof parsed?.ok !== "boolean") {
throw new Error(
`Invalid prompt hook response: "ok" must be a boolean, got ${typeof parsed?.ok}`,
);
}
return parsed as PromptHookResponse;
} catch (e) {
// Re-throw validation errors as-is
if (e instanceof Error && e.message.startsWith("Invalid prompt hook")) {
throw e;
}
// If parsing fails, treat as error
throw new Error(`Failed to parse LLM response as JSON: ${response}`);
}
}
/**
* Convert PromptHookResponse to HookResult
*/
function responseToHookResult(
response: PromptHookResponse,
durationMs: number,
): HookResult {
// ok: true allows the action, ok: false (or missing) blocks it
const shouldBlock = response.ok !== true;
return {
exitCode: shouldBlock ? HookExitCode.BLOCK : HookExitCode.ALLOW,
stdout: JSON.stringify(response),
stderr: shouldBlock ? response.reason || "" : "",
timedOut: false,
durationMs,
};
}
/**
* Extract agent_id from hook input, falling back to the global agent context.
*/
function getAgentId(input: HookInput): string | undefined {
// 1. Check hook input directly (most hook event types include agent_id)
if ("agent_id" in input && input.agent_id) {
return input.agent_id;
}
// 2. Fall back to the global agent context (set during session)
try {
return getCurrentAgentId();
} catch {
// Context not available
}
// 3. Last resort: env var (set by shell env for subprocesses)
return process.env.LETTA_AGENT_ID;
}
/**
* JSON schema for structured prompt hook responses.
* Forces the LLM to return {ok: boolean, reason?: string} via tool calling.
*/
const PROMPT_HOOK_RESPONSE_SCHEMA = {
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"],
};
/** Response shape from POST /v1/agents/{agent_id}/generate */
interface GenerateResponse {
content: string;
model: string;
usage: {
completion_tokens: number;
prompt_tokens: number;
total_tokens: number;
};
}
/**
* Execute a prompt-based hook by sending the hook input to an LLM
* via the POST /v1/agents/{agent_id}/generate endpoint.
*/
export async function executePromptHook(
hook: PromptHookConfig,
input: HookInput,
_workingDirectory: string = process.cwd(),
): Promise<HookResult> {
const startTime = Date.now();
try {
const agentId = getAgentId(input);
if (!agentId) {
throw new Error(
"Prompt hooks require an agent_id. Ensure the hook event provides an agent_id " +
"or set the LETTA_AGENT_ID environment variable.",
);
}
// Build the user prompt with $ARGUMENTS replaced
const userPrompt = buildPrompt(hook.prompt, input);
const timeout = hook.timeout ?? DEFAULT_PROMPT_TIMEOUT_MS;
// Call the generate endpoint (uses agent's model unless hook overrides)
const llmResponse = await callGenerateEndpoint(
agentId,
PROMPT_HOOK_SYSTEM,
userPrompt,
hook.model,
timeout,
);
// Parse the response
const parsedResponse = parsePromptResponse(llmResponse);
const durationMs = Date.now() - startTime;
// Log hook completion (matching command hook format from executor.ts)
const shouldBlock = parsedResponse.ok !== true;
const exitCode = shouldBlock ? 2 : 0;
const exitColor = shouldBlock ? "\x1b[31m" : "\x1b[32m";
const exitLabel = `${exitColor}exit ${exitCode}\x1b[0m`;
const promptLabel = `\x1b[38;2;140;140;249m✦\x1b[90m ${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
console.log(`\x1b[90m[hook:${input.event_type}] ${promptLabel}\x1b[0m`);
console.log(`\x1b[90m \u23BF ${exitLabel} (${durationMs}ms)\x1b[0m`);
// Show the JSON response as stdout
const responseJson = JSON.stringify(parsedResponse);
console.log(`\x1b[90m \u23BF (stdout)\x1b[0m`);
console.log(`\x1b[90m ${responseJson}\x1b[0m`);
return responseToHookResult(parsedResponse, durationMs);
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
const timedOut = errorMessage.includes("timed out");
const promptLabel = `\x1b[38;2;140;140;249m✦\x1b[90m ${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
console.log(`\x1b[90m[hook:${input.event_type}] ${promptLabel}\x1b[0m`);
console.log(
`\x1b[90m \u23BF \x1b[33mexit 1\x1b[0m (${durationMs}ms)\x1b[0m`,
);
console.log(`\x1b[90m \u23BF (stderr)\x1b[0m`);
console.log(`\x1b[90m ${errorMessage}\x1b[0m`);
return {
exitCode: HookExitCode.ERROR,
stdout: "",
stderr: errorMessage,
timedOut,
durationMs,
error: errorMessage,
};
}
}
/**
* Call the POST /v1/agents/{agent_id}/generate endpoint for hook evaluation.
* Uses the Letta SDK client's raw post() method since the SDK doesn't have
* a typed generate() method yet.
*/
async function callGenerateEndpoint(
agentId: string,
systemPrompt: string,
userPrompt: string,
overrideModel: string | undefined,
timeout: number,
): Promise<string> {
const client = await getClient();
const response = await client.post<GenerateResponse>(
`/v1/agents/${agentId}/generate`,
{
body: {
prompt: userPrompt,
system_prompt: systemPrompt,
...(overrideModel && { override_model: overrideModel }),
response_schema: PROMPT_HOOK_RESPONSE_SCHEMA,
},
timeout,
},
);
return response.content;
}

View File

@@ -29,17 +29,82 @@ export type SimpleHookEvent =
export type HookEvent = ToolHookEvent | SimpleHookEvent;
/**
* Individual hook command configuration
* Command hook configuration - executes a shell command
*/
export interface HookCommand {
/** Type of hook - currently only "command" is supported */
export interface CommandHookConfig {
/** Type of hook */
type: "command";
/** Shell command to execute */
command: string;
/** Optional timeout in milliseconds (default: 60000) */
/** Optional timeout in milliseconds (default: 60000 for command hooks) */
timeout?: number;
}
/**
* Prompt hook configuration - sends hook input to an LLM for evaluation.
* Supported events: PreToolUse, PostToolUse, PostToolUseFailure,
* PermissionRequest, UserPromptSubmit, Stop, and SubagentStop.
*/
export interface PromptHookConfig {
/** Type of hook */
type: "prompt";
/**
* Prompt text to send to the model.
* Use $ARGUMENTS as a placeholder for the hook input JSON.
*/
prompt: string;
/** Optional model to use for evaluation */
model?: string;
/** Optional timeout in milliseconds (default: 30000 for prompt hooks) */
timeout?: number;
}
/**
* Placeholder for $ARGUMENTS in prompt hooks
*/
export const PROMPT_ARGUMENTS_PLACEHOLDER = "$ARGUMENTS";
/**
* Events that support prompt-based hooks:
* PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest,
* UserPromptSubmit, Stop, SubagentStop
*/
export const PROMPT_HOOK_SUPPORTED_EVENTS: Set<HookEvent> = new Set([
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"UserPromptSubmit",
"Stop",
"SubagentStop",
]);
/**
* Type guard to check if an event supports prompt hooks
*/
export function supportsPromptHooks(event: HookEvent): boolean {
return PROMPT_HOOK_SUPPORTED_EVENTS.has(event);
}
/**
* Individual hook configuration - can be command or prompt type
*/
export type HookCommand = CommandHookConfig | PromptHookConfig;
/**
* Type guard to check if a hook is a command hook
*/
export function isCommandHook(hook: HookCommand): hook is CommandHookConfig {
return hook.type === "command";
}
/**
* Type guard to check if a hook is a prompt hook
*/
export function isPromptHook(hook: HookCommand): hook is PromptHookConfig {
return hook.type === "prompt";
}
/**
* Hook matcher configuration for tool events - matches hooks to specific tools
*/
@@ -125,6 +190,17 @@ export interface HookResult {
error?: string;
}
/**
* Expected JSON response structure from prompt hooks.
* The LLM must respond with this schema per Claude Code spec.
*/
export interface PromptHookResponse {
/** true allows the action, false prevents it */
ok: boolean;
/** Required when ok is false. Explanation shown to Claude. */
reason?: string;
}
/**
* Aggregated result from running all matched hooks
*/
@@ -281,6 +357,8 @@ export interface StopHookInput extends HookInputBase {
preceding_reasoning?: string;
/** The assistant's final message content */
assistant_message?: string;
/** The user's original prompt that initiated this turn */
user_message?: string;
}
/**