feat: add post tool use failure hooks (#796)

This commit is contained in:
jnjpng
2026-02-03 14:13:27 -08:00
committed by GitHub
parent 47026479b1
commit 6947e8d837
7 changed files with 344 additions and 7 deletions

View File

@@ -56,6 +56,7 @@ type Screen =
const HOOK_EVENTS: { event: HookEvent; description: string }[] = [
{ event: "PreToolUse", description: "Before tool execution" },
{ event: "PostToolUse", description: "After tool execution" },
{ event: "PostToolUseFailure", description: "After tool execution fails" },
{ event: "PermissionRequest", description: "When permission is requested" },
{ event: "UserPromptSubmit", description: "When user submits a prompt" },
{ event: "Notification", description: "When notifications are sent" },

View File

@@ -138,13 +138,23 @@ function executeWithLauncher(
if (!resolved) {
resolved = true;
// Log hook completion with command for context
const exitLabel =
// Show exit code with color: green for 0, red for 2, yellow for errors
const exitCode =
result.exitCode === HookExitCode.ALLOW
? "\x1b[32m✓ allowed\x1b[0m"
? 0
: result.exitCode === HookExitCode.BLOCK
? "\x1b[31m✗ blocked\x1b[0m"
: "\x1b[33m⚠ error\x1b[0m";
console.log(`\x1b[90m[hook] ${command}\x1b[0m`);
? 2
: 1;
const exitColor =
result.exitCode === HookExitCode.ALLOW
? "\x1b[32m"
: result.exitCode === HookExitCode.BLOCK
? "\x1b[31m"
: "\x1b[33m";
const exitLabel = result.timedOut
? `${exitColor}timeout\x1b[0m`
: `${exitColor}exit ${exitCode}\x1b[0m`;
console.log(`\x1b[90m[hook:${input.event_type}] ${command}\x1b[0m`);
console.log(
`\x1b[90m \u23BF ${exitLabel} (${result.durationMs}ms)\x1b[0m`,
);

View File

@@ -9,6 +9,7 @@ import type {
HookExecutionResult,
NotificationHookInput,
PermissionRequestHookInput,
PostToolUseFailureHookInput,
PostToolUseHookInput,
PreCompactHookInput,
PreToolUseHookInput,
@@ -100,6 +101,57 @@ export async function runPostToolUseHooks(
return executeHooksParallel(hooks, input, workingDirectory);
}
/**
* Run PostToolUseFailure hooks after a tool has failed
* These run in parallel and cannot block (tool already failed)
* Stderr from hooks with exit code 2 is fed back to the agent
*/
export async function runPostToolUseFailureHooks(
toolName: string,
toolInput: Record<string, unknown>,
errorMessage: string,
errorType?: string,
toolCallId?: string,
workingDirectory: string = process.cwd(),
agentId?: string,
precedingReasoning?: string,
precedingAssistantMessage?: string,
): Promise<HookExecutionResult> {
const hooks = await getHooksForEvent(
"PostToolUseFailure",
toolName,
workingDirectory,
);
if (hooks.length === 0) {
return { blocked: false, errored: false, feedback: [], results: [] };
}
const input: PostToolUseFailureHookInput = {
event_type: "PostToolUseFailure",
working_directory: workingDirectory,
tool_name: toolName,
tool_input: toolInput,
tool_call_id: toolCallId,
error_message: errorMessage,
error_type: errorType,
agent_id: agentId,
preceding_reasoning: precedingReasoning,
preceding_assistant_message: precedingAssistantMessage,
};
// Run in parallel since PostToolUseFailure cannot block
// Use standard executeHooksParallel - feedback collected on exit 2
const result = await executeHooksParallel(hooks, input, workingDirectory);
// PostToolUseFailure never actually blocks (tool already failed)
return {
blocked: false,
errored: result.errored,
feedback: result.feedback,
results: result.results,
};
}
/**
* Run PermissionRequest hooks when a permission dialog would be shown
* Can auto-allow (exit 0) or auto-deny (exit 2) the permission

View File

@@ -7,6 +7,7 @@
export type ToolHookEvent =
| "PreToolUse" // Runs before tool calls (can block them)
| "PostToolUse" // Runs after tool calls complete (cannot block)
| "PostToolUseFailure" // Runs after tool calls fail (cannot block, feeds stderr back to agent)
| "PermissionRequest"; // Runs when a permission dialog is shown (can allow or deny)
/**
@@ -83,6 +84,7 @@ export type HooksConfig = {
export const TOOL_EVENTS: Set<HookEvent> = new Set([
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
]);
@@ -192,6 +194,30 @@ export interface PostToolUseHookInput extends HookInputBase {
preceding_assistant_message?: string;
}
/**
* Input for PostToolUseFailure hooks
* Triggered after a tool call fails. Non-blocking, but stderr is fed back to the agent.
*/
export interface PostToolUseFailureHookInput extends HookInputBase {
event_type: "PostToolUseFailure";
/** Name of the tool that failed */
tool_name: string;
/** Tool input arguments */
tool_input: Record<string, unknown>;
/** Tool call ID */
tool_call_id?: string;
/** Error message from the tool failure */
error_message: string;
/** Error type/name (e.g., "AbortError", "TypeError") */
error_type?: string;
/** Agent ID (for server-side tools like memory) */
agent_id?: string;
/** Reasoning/thinking content that preceded this tool call */
preceding_reasoning?: string;
/** Assistant message content that preceded this tool call */
preceding_assistant_message?: string;
}
/**
* Input for PermissionRequest hooks
*/
@@ -338,6 +364,7 @@ export interface SessionEndHookInput extends HookInputBase {
export type HookInput =
| PreToolUseHookInput
| PostToolUseHookInput
| PostToolUseFailureHookInput
| PermissionRequestHookInput
| UserPromptSubmitHookInput
| NotificationHookInput

View File

@@ -10,6 +10,7 @@ import {
import {
type HookCommand,
HookExitCode,
type PostToolUseFailureHookInput,
type PostToolUseHookInput,
type PreToolUseHookInput,
type SessionStartHookInput,
@@ -470,6 +471,53 @@ describe.skipIf(isWindows)("Hooks Executor", () => {
expect(parsed.is_new_session).toBe(true);
expect(parsed.agent_name).toBe("Test Agent");
});
test("handles PostToolUseFailure input with error details", async () => {
const hook: HookCommand = { type: "command", command: "cat" };
const input: PostToolUseFailureHookInput = {
event_type: "PostToolUseFailure",
working_directory: tempDir,
tool_name: "Bash",
tool_input: { command: "echho hello" },
tool_call_id: "call-123",
error_message: "zsh:1: command not found: echho",
error_type: "tool_error",
agent_id: "agent-456",
};
const result = await executeHookCommand(hook, input, tempDir);
expect(result.exitCode).toBe(HookExitCode.ALLOW);
const parsed = JSON.parse(result.stdout);
expect(parsed.event_type).toBe("PostToolUseFailure");
expect(parsed.tool_name).toBe("Bash");
expect(parsed.error_message).toBe("zsh:1: command not found: echho");
expect(parsed.error_type).toBe("tool_error");
expect(parsed.tool_input.command).toBe("echho hello");
});
test("PostToolUseFailure hook can provide feedback via stderr with exit 0", async () => {
const hook: HookCommand = {
type: "command",
command: "echo 'Suggestion: check spelling of command' >&2 && exit 0",
};
const input: PostToolUseFailureHookInput = {
event_type: "PostToolUseFailure",
working_directory: tempDir,
tool_name: "Bash",
tool_input: { command: "echho hello" },
error_message: "command not found: echho",
error_type: "tool_error",
};
const result = await executeHookCommand(hook, input, tempDir);
// Exit 0 = success, stderr should still be captured
expect(result.exitCode).toBe(HookExitCode.ALLOW);
expect(result.stderr).toBe("Suggestion: check spelling of command");
});
});
describe("Edge cases", () => {

View File

@@ -9,6 +9,7 @@ import {
hasHooks,
runNotificationHooks,
runPermissionRequestHooks,
runPostToolUseFailureHooks,
runPostToolUseHooks,
runPreCompactHooks,
runPreToolUseHooks,
@@ -275,6 +276,133 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
});
});
// ============================================================================
// PostToolUseFailure Hooks
// ============================================================================
describe("PostToolUseFailure hooks", () => {
test("runs after tool failure", async () => {
createHooksConfig({
PostToolUseFailure: [
{
matcher: "Bash",
hooks: [
{
type: "command",
command: "echo 'hook ran'",
},
],
},
],
});
const result = await runPostToolUseFailureHooks(
"Bash",
{ command: "echho hello" },
"command not found: echho",
"tool_error",
"tool-789",
tempDir,
);
// PostToolUseFailure never blocks
expect(result.blocked).toBe(false);
expect(result.results).toHaveLength(1);
expect(result.results[0]?.stdout).toBe("hook ran");
});
test("collects stderr feedback on exit 2", async () => {
createHooksConfig({
PostToolUseFailure: [
{
matcher: "*",
hooks: [
{
type: "command",
command: "echo 'Try checking spelling' >&2 && exit 2",
},
],
},
],
});
const result = await runPostToolUseFailureHooks(
"Bash",
{ command: "bad-cmd" },
"command not found",
"tool_error",
undefined,
tempDir,
);
// Stderr collected as feedback on exit 2
expect(result.feedback).toHaveLength(1);
expect(result.feedback[0]).toContain("Try checking spelling");
});
test("receives error details in input", async () => {
createHooksConfig({
PostToolUseFailure: [
{
matcher: "*",
hooks: [{ type: "command", command: "cat" }],
},
],
});
const result = await runPostToolUseFailureHooks(
"Bash",
{ command: "nonexistent-cmd" },
"zsh:1: command not found: nonexistent-cmd",
"tool_error",
"call-123",
tempDir,
"agent-456",
);
const parsed = JSON.parse(result.results[0]?.stdout || "{}");
expect(parsed.event_type).toBe("PostToolUseFailure");
expect(parsed.tool_name).toBe("Bash");
expect(parsed.error_message).toBe(
"zsh:1: command not found: nonexistent-cmd",
);
expect(parsed.error_type).toBe("tool_error");
expect(parsed.tool_call_id).toBe("call-123");
expect(parsed.agent_id).toBe("agent-456");
});
test("never blocks even with exit 2", async () => {
createHooksConfig({
PostToolUseFailure: [
{
matcher: "*",
hooks: [
{
type: "command",
command: "echo 'feedback with exit 2' >&2 && exit 2",
},
],
},
],
});
const result = await runPostToolUseFailureHooks(
"Bash",
{ command: "bad" },
"error",
undefined,
undefined,
tempDir,
);
// PostToolUseFailure should never block - tool already failed
expect(result.blocked).toBe(false);
// Stderr collected as feedback on exit 2
expect(result.feedback).toHaveLength(1);
expect(result.feedback[0]).toContain("feedback with exit 2");
});
});
// ============================================================================
// PermissionRequest Hooks
// ============================================================================

View File

@@ -2,7 +2,11 @@ import { getDisplayableToolReturn } from "../agent/approval-execution";
import { getModelInfo } from "../agent/model";
import { getAllSubagentConfigs } from "../agent/subagents";
import { INTERRUPTED_BY_USER } from "../constants";
import { runPostToolUseHooks, runPreToolUseHooks } from "../hooks";
import {
runPostToolUseFailureHooks,
runPostToolUseHooks,
runPreToolUseHooks,
} from "../hooks";
import { telemetry } from "../telemetry";
import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions";
@@ -972,6 +976,51 @@ export async function executeTool(
// Silently ignore hook errors - don't affect tool execution
});
// Run PostToolUseFailure hooks when tool returns error status (async, feeds stderr back to agent)
if (toolStatus === "error") {
const errorOutput =
typeof flattenedResponse === "string"
? flattenedResponse
: JSON.stringify(flattenedResponse);
try {
const failureHookResult = await runPostToolUseFailureHooks(
internalName,
args as Record<string, unknown>,
errorOutput,
"tool_error", // error type for returned errors
options?.toolCallId,
undefined, // workingDirectory
undefined, // agentId
undefined, // precedingReasoning - not available in tool manager context
undefined, // precedingAssistantMessage - not available in tool manager context
);
// Feed stderr (feedback) back to the agent
if (failureHookResult.feedback.length > 0) {
const feedbackMessage = `\n\n[PostToolUseFailure hook feedback]:\n${failureHookResult.feedback.join("\n")}`;
let finalToolReturn: ToolReturnContent;
if (typeof flattenedResponse === "string") {
finalToolReturn = flattenedResponse + feedbackMessage;
} else if (Array.isArray(flattenedResponse)) {
// Append feedback as a new text content block
finalToolReturn = [
...flattenedResponse,
{ type: "text" as const, text: feedbackMessage },
];
} else {
finalToolReturn = flattenedResponse;
}
return {
toolReturn: finalToolReturn,
status: toolStatus,
...(stdout && { stdout }),
...(stderr && { stderr }),
};
}
} catch {
// Silently ignore hook execution errors
}
}
// Return the full response (truncation happens in UI layer only)
return {
toolReturn: flattenedResponse,
@@ -1022,10 +1071,32 @@ export async function executeTool(
// Silently ignore hook errors
});
// Run PostToolUseFailure hooks (async, non-blocking, feeds stderr back to agent)
let finalErrorMessage = errorMessage;
try {
const failureHookResult = await runPostToolUseFailureHooks(
internalName,
args as Record<string, unknown>,
errorMessage,
errorType,
options?.toolCallId,
undefined, // workingDirectory
undefined, // agentId
undefined, // precedingReasoning - not available in tool manager context
undefined, // precedingAssistantMessage - not available in tool manager context
);
// Feed stderr (feedback) back to the agent
if (failureHookResult.feedback.length > 0) {
finalErrorMessage = `${errorMessage}\n\n[PostToolUseFailure hook feedback]:\n${failureHookResult.feedback.join("\n")}`;
}
} catch {
// Silently ignore hook execution errors
}
// Don't console.error here - it pollutes the TUI
// The error message is already returned in toolReturn
return {
toolReturn: errorMessage,
toolReturn: finalErrorMessage,
status: "error",
};
}