Skip to content

Hook input has no identifier grouping tool calls within one assistant response #618

Open

Description

@stig

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

  1. stig commented on Sep 24, 2026

    @stig
    Author

    Correction and better evidence, after building the hook and getting it wrong several times.

    Retracting one figure. The git commit then git 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 status and a git commit that 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.lock and 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 for postToolCall, 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 agent does not separate one of a subagent's turns from the next. A request_id would 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.

  2. self-assigned this
    on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions