Repository navigation
Hook input has no identifier grouping tool calls within one assistant response #618
Description
What I'm trying to do
Enforce "one git write per assistant response" with a preToolCall hook. Tool calls within a single response run concurrently, so a git write batched with any other git command contends on index.lock, and a read-back issued alongside its write can report pre-write state. Both fail silently, which is what makes a hook the right tool: the failure is invisible in the transcript, so a rule in the system prompt doesn't survive contact with an instruction to batch independent calls.
The gap
To decide whether two calls belong to the same response, the hook needs to know which response each came from. Nothing in the input says.
Documented chat-scoped input gives chat_id, session_id, agent, behavior, full_model, variant, and preToolCall adds tool_name, server, tool_input, tool_call_id, approval. tool_call_id is per call, chat_id is per conversation. There is no identifier at the granularity in between.
Why a timestamp heuristic doesn't substitute
Correction, see the comment below. The 3562ms row in this table was not a measurement and is retracted. The real picture is worse for heuristics, not better: same-response gaps reach 10717ms, because the gap includes however long the model spent emitting the second call. The two distributions overlap completely rather than nearly touching, so no threshold exists at all.
I tried a time window, on the assumption that calls within one response dispatch milliseconds apart while consecutive responses are separated by an LLM round-trip. Measured gaps between consecutive preToolCall firings, one session:
| calls | same response | gap |
|---|---|---|
two eca__git reads |
yes | 62ms |
two eca__shell_command |
yes | 64ms |
git commit then git log |
yes | 3562ms |
| assorted | no | 5008ms, 6628ms, 9007ms |
Same-response gaps span 62ms to 3562ms and cross-response gaps start at 5008ms, so the distributions nearly touch. Any threshold either misses real batches or blocks the first call of the next response. I confirmed the concurrency is real rather than an artefact: two eca__shell_command calls each running sleep 3 overlapped for the full three seconds.
Proposal
Add a per-response identifier to chat-scoped hook input, something like request_id or turn_id, stable across every hook firing within one assistant response and different for the next. A hook can then group calls exactly, with no heuristic.
This would also give tool hooks a reliable key for per-turn state. agent distinguishes a subagent from the primary agent, but not one of its turns from the next, so any hook keeping state across calls currently has the same problem.
Alternative considered
read-chat can reach the chat history from a hook, but it runs synchronously inside a 30s budget on every matching tool call, which is a lot of work to answer "which response is this". A field in the payload is far cheaper.
Activity
stig commentedon Sep 24, 2026
Correction and better evidence, after building the hook and getting it wrong several times.
Retracting one figure. The
git committhengit log/ 3562ms row in the table above was not a measurement. I read two adjacent lines in my hook's log and assumed they were a batched pair. Please disregard that row.What the gaps actually look like. Calls in one response are not dispatched together. ECA starts the next one when the previous returns, and the gap between them therefore includes however long the model spent emitting the second call. A
git statusand agit committhat I know were in a single response were 10717ms apart, end to start, because the commit carried a long heredoc message. Meanwhile the smallest cross-response gap I can find is around 2.4s.So the two distributions do not merely touch, they overlap completely: same-response gaps run from negative to over ten seconds. There is no threshold. Every one I derived looked convincing only because I had clustered the pairs on the very gap I was then validating, which quietly filed each slow-first-call pair as cross-response.
Concurrency is intermittent, not universal. Of 75 consecutive git or shell calls with both ends recorded, 11 pairs overlapped, one by 11 seconds. The rest ran strictly in sequence.
Weakening my own use case, for the record. That last point gave me a way out without a response id. Since sequential calls cannot contend on
index.lockand cannot produce a stale read-back, overlap is the hazard rather than co-residence in a response, and overlap is directly observable: register the same hook forpostToolCall, record completion, and treat a start with no end as still running. No clock, no id. My gate now works this way.What stands is the general half of the request. Any hook keeping state per turn still has no key for it, and
agentdoes not separate one of a subagent's turns from the next. Arequest_idwould also let a hook tell "sequential within one response" from "concurrent", which overlap detection alone cannot distinguish from work in two different responses.Happy to leave this open on that basis, or close it if the narrower need is what mattered.