From f418afa2756e887d1428b52daa98ff3ed0520276 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 25 Sep 2026 03:16:01 +0100 Subject: [PATCH] feat(observe): record real tool failures from PostToolUseFailure Claude Code sends failed tool calls to the PostToolUseFailure event. ADAM registered only PostToolUse, so it never saw a real failure, and every tool_error_loop came from ERROR_RE matching successful output. Handle PostToolUseFailure in adam-observe.mjs through the PostToolUse detectors. Shape the error string as an is_error response. An interrupt is an abort, so it counts as a tool event but gets no fingerprint. Register the event in settings.json.example. Tests 9b and 9c added: 150 passed, 0 failed. Claude-Session: https://claude.ai/code/session_014prTNuJnKuwcAd28vUZAnN --- adam/tests/run-tests.sh | 19 +++++++++++++++++++ hooks/adam-observe.mjs | 15 ++++++++++----- settings.json.example | 11 +++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/adam/tests/run-tests.sh b/adam/tests/run-tests.sh index 023a4b2..2767d88 100755 --- a/adam/tests/run-tests.sh +++ b/adam/tests/run-tests.sh @@ -187,6 +187,25 @@ for i in 1 2 3; do done assert_grep "$ROOT/journal.jsonl" '"type":"tool_error_loop"' "3x same error logs tool_error_loop" +# --- Test 9b: PostToolUseFailure feeds the same detectors --- +echo "Test 9b: 3x PostToolUseFailure with the same error logs tool_error_loop" +reset_state +for i in 1 2 3; do + echo '{"hook_event_name":"PostToolUseFailure","tool_name":"Bash","tool_input":{"command":"npm test"},"error":"Exit code 1\nError: Cannot find module express","is_interrupt":false,"session_id":"s9b","cwd":"/tmp/x"}' \ + | HOOK_RUN >/dev/null 2>&1 || true +done +assert_grep "$ROOT/journal.jsonl" '"type":"tool_error_loop"' "3x PostToolUseFailure logs tool_error_loop" + +# --- Test 9c: interrupted tool calls are not errors --- +echo "Test 9c: 8x PostToolUseFailure with is_interrupt:true counts as tool use but logs no tool_error_loop" +reset_state +for i in 1 2 3 4 5 6 7 8; do + echo '{"hook_event_name":"PostToolUseFailure","tool_name":"Bash","tool_input":{"command":"sleep 99"},"error":"Error: aborted","is_interrupt":true,"session_id":"s9c","cwd":"/tmp/x"}' \ + | HOOK_RUN >/dev/null 2>&1 || true +done +assert_grep "$ROOT/journal.jsonl" '"type":"dead_end"' "interrupted calls still count as tool events" +assert_no_grep "$ROOT/journal.jsonl" '"type":"tool_error_loop"' "interrupts do not log tool_error_loop" + # --- Test 10: dead_end on long autonomous run --- echo "Test 10: dead_end after 8 tools without UserPromptSubmit" reset_state diff --git a/hooks/adam-observe.mjs b/hooks/adam-observe.mjs index b99bb47..bc546c9 100755 --- a/hooks/adam-observe.mjs +++ b/hooks/adam-observe.mjs @@ -465,12 +465,17 @@ function main() { state.subagent_dispatch_emitted[name] = true; } } - } else if (event === "PostToolUse") { + } else if (event === "PostToolUse" || event === "PostToolUseFailure") { const tool = input.tool_name || "unknown"; const argsHash = djb2(JSON.stringify(input.tool_input || {})); const file = (input.tool_input && (input.tool_input.file_path || input.tool_input.path)) || null; - const toolResponse = input.tool_response; + // Claude Code sends failed tool calls to PostToolUseFailure with the error + // text in `error`. Shape it like an is_error response so every PostToolUse + // detector sees real failures. An interrupt is an abort, not a tool error. + const toolResponse = event === "PostToolUseFailure" + ? { is_error: input.is_interrupt !== true, content: String(input.error || "") } + : input.tool_response; const respExcerpt = (() => { if (!toolResponse) return null; const text = typeof toolResponse === "string" ? toolResponse @@ -541,11 +546,11 @@ function main() { } } - if (input.tool_response && typeof input.tool_response === "object") { + if (toolResponse && typeof toolResponse === "object") { bumpUsage("payload:tool_response_seen"); } - const fp = errorFingerprint(input.tool_response); + const fp = errorFingerprint(toolResponse); if (fp) { bumpUsage("payload:tool_response_error_seen"); if (state.recentRecoveries.length) { @@ -590,7 +595,7 @@ function main() { if (tool === "Bash") { const cmd = (input.tool_input && input.tool_input.command) || ""; const isBuildCmd = BUILD_RE.test(cmd); - const hasError = (input.tool_response && input.tool_response.is_error === true) || fp !== null; + const hasError = (toolResponse && toolResponse.is_error === true) || fp !== null; if (isBuildCmd && hasError) { state.build_failure_count += 1; if (state.build_failure_count >= BUILD_LOOP_THRESHOLD && !state.build_loop_emitted) { diff --git a/settings.json.example b/settings.json.example index 7ce66d2..c5c420f 100644 --- a/settings.json.example +++ b/settings.json.example @@ -34,6 +34,17 @@ ] } ], + "PostToolUseFailure": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$HOME/.claude/hooks/adam-observe.mjs\"" + } + ] + } + ], "SessionStart": [ { "hooks": [