KeiSeiKit-1.0/hooks/agent-event-done.sh
Parfii-bot 4e7463ef0a fix(live-graph): tool_use events properly attribute to spawning agent
User pushback: live-graph showed only "main" node, no pulses on agents.
Root cause: hook stdin doesn't carry parent_tool_use_id for sub-agent
tool calls — we only get the sub-agent's own session_id, which doesn't
link back to the spawn's tool_use_id.

Sequential heuristic via shared state file:
  - agent-event-spawn.sh appends tool_use_id to /tmp/kei-active-children.tsv
  - tool-use-event.sh reads the LAST line of that file → uses that
    tool_use_id as agent_id for the emitted event
  - agent-event-done.sh removes the spawn's line (grep -v + atomic mv)

Verified end-to-end: a code-implementer agent ran 5 Bash calls during
its lifetime — all 5 tool_use events were correctly attributed to the
spawn's tool_use_id. After agent_done, subsequent orchestrator-direct
tool calls correctly fall back to agent_id="main".

Limitation: parallel agents may misattribute. The "most recent live
spawn" heuristic works for single-agent-at-a-time which is the common
case. Parallel spawns share /tmp/kei-active-children.tsv and a sub-
agent's tool calls all attribute to whichever spawn appended last.
Acceptable for v1 demo; proper parent-tool-use-id propagation requires
Claude Code to expose it in sub-agent stdin (upstream change).

The `mv` after `grep -v` runs UNCONDITIONALLY (not gated on grep's
exit code) — grep -v returns 1 when ALL lines match, which would
otherwise leave the stale file in place.

Bypass: `KEI_EVENTS_BYPASS=1` (existing) covers all 3 hooks.
Override path: `KEI_ACTIVE_SPAWNS_FILE=/path/to/file`.

=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: NOT-RUN
behaviour-verified: yes
follow-up-required:
  - Parallel-agent attribution would need parent_tool_use_id from
    Claude Code sub-agent stdin (not currently exposed).
  - Race condition window between spawn append and done remove is
    millisecond-scale; observed clean in single-agent demo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:43:42 +08:00

68 lines
3.3 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/sh
# agent-event-done.sh — PostToolUse:Agent hook.
# Emits `agent_done` event to ~/.claude/memory/agent-events.jsonl
# per the locked schema at /tmp/agent-events-schema.md (2026-05-02).
# Reuses STATUS-TRUTH MARKER parsing from agent-outcome-backfill.sh.
# Defensive: never blocks, exits 0. Bypass: KEI_EVENTS_BYPASS=1.
set -u
[ "${KEI_EVENTS_BYPASS:-0}" = "1" ] && exit 0
command -v jq >/dev/null 2>&1 || exit 0
PAYLOAD=$(cat 2>/dev/null || true)
[ -n "$PAYLOAD" ] || exit 0
TOOL=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
[ "$TOOL" = "Agent" ] || exit 0
EVENTS_FILE="$HOME/.claude/memory/agent-events.jsonl"
mkdir -p "$(dirname "$EVENTS_FILE")" 2>/dev/null || true
TOOL_USE_ID=$(printf '%s' "$PAYLOAD" | jq -r '.tool_use_id // .toolUseId // "unknown"' 2>/dev/null)
# Flatten tool_response content to plain text (pattern from agent-outcome-backfill.sh).
RESPONSE=$(printf '%s' "$PAYLOAD" | jq -r '
(.tool_response // "") as $r | def f:
if type=="string" then . elif type=="array" then map(f)|join("\n")
elif type=="object" then (if has("text") then .text elif has("content") then .content|f else tostring end)
else "" end; $r|f' 2>/dev/null || true)
# Parse outcome from STATUS-TRUTH MARKER; null if absent or unrecognized.
OUTCOME="null"
if printf '%s' "$RESPONSE" | grep -q '=== STATUS-TRUTH MARKER ===' 2>/dev/null; then
SHIPPED=$(printf '%s' "$RESPONSE" | grep -m1 '^shipped:' \
| sed 's/^shipped:[[:space:]]*//' | awk '{print tolower($1)}' 2>/dev/null || true)
case "$SHIPPED" in functional|partial|scaffolding|fail) OUTCOME="\"$SHIPPED\"";; esac
fi
# Cost estimate from token counts × rough per-token price constants.
MODEL=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.model // .tool_input.model // ""' 2>/dev/null | tr '[:upper:]' '[:lower:]')
IN_TOK=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.usage.input_tokens // 0' 2>/dev/null)
OUT_TOK=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.usage.output_tokens // 0' 2>/dev/null)
cost_usd=$(awk -v m="$MODEL" -v i="$IN_TOK" -v o="$OUT_TOK" 'BEGIN{
if(index(m,"haiku")>0){p=0.000001;q=0.000005}
else if(index(m,"sonnet")>0){p=0.000003;q=0.000015}
else if(index(m,"opus")>0){p=0.000005;q=0.000025}
else{print "null";exit}; printf "%.6f",i*p+o*q}' 2>/dev/null || echo "null")
jq -cn \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null)" \
--arg id "$TOOL_USE_ID" \
--argjson outcome "$OUTCOME" \
--argjson duration_ms "$(printf '%s' "$PAYLOAD" | jq '.duration_ms // .tool_response.totalDurationMs // null' 2>/dev/null)" \
--argjson tool_use_count "$(printf '%s' "$PAYLOAD" | jq '.tool_response.totalToolUseCount // null' 2>/dev/null)" \
--argjson cost_usd "$cost_usd" \
'{ts:$ts,event:"agent_done",id:$id,outcome:$outcome,
duration_ms:$duration_ms,tool_use_count:$tool_use_count,cost_usd:$cost_usd}' \
>> "$EVENTS_FILE" 2>/dev/null || true
# Remove this spawn from active-children ledger (mirror of spawn hook).
# `grep -v` returns exit 1 when the file becomes empty, so the `mv` runs
# UNCONDITIONALLY (not gated on grep's exit status).
ACTIVE_FILE="${KEI_ACTIVE_SPAWNS_FILE:-/tmp/kei-active-children.tsv}"
if [ -n "$TOOL_USE_ID" ] && [ -f "$ACTIVE_FILE" ]; then
grep -v " $TOOL_USE_ID\$" "$ACTIVE_FILE" > "$ACTIVE_FILE.tmp" 2>/dev/null
mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true
fi
exit 0