feat: add post tool use failure hooks (#796)
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user