You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
build_tier_call_futures fires PreToolUse hooks for each tool call in a tier sequentially, inside the loop that builds the tier's execution futures — before any of the tier's tool executions even start. This is the same class of problem that #6128 fixed for the PostToolUse side in apply_tier_results, but the fix was never mirrored to the PreToolUse side.
apply_tier_results Phase 2 explicitly documents why it parallelizes hook firing (bounded by max_parallel semaphore):
// Phase 2: RuntimeLayer::after_tool and PostToolUse hook firing per index. Unlike// process_one_tool_result (which must stay strictly sequential to preserve// OpenAI/Claude message-ordering...), there is no ordering constraint between// different tool-result indices here... Run them concurrently, bounded by the// same `max_parallel` semaphore the tier's tool execution itself already uses,// so a tier with many hook-matching calls doesn't serialize N subprocess// spawns after already paying for bounded-parallel tool execution (#6128).
build_tier_call_futures's PreToolUse firing has no such treatment — it awaits fire_hooks(...) once per idx in a plain for loop before pushing to tier_futs:
For a tier of N tool calls that all match a PreToolUse hook (e.g. a subprocess-based guardrail), this adds N × hook_latency of purely sequential blocking on the agent turn loop — the project's hottest hot path per CLAUDE.md's non-blocking contract — before the tier's already-parallelized tool execution even begins. The ordering constraint that requires each hook to fire before its own call's gate checks (documented at the top of the loop, so the hook always observes the LLM's request even when a gate later intercepts it) does not require sequentiality across different idx values — each call's PreToolUse firing is independent of every other call's, exactly like the PostToolUse case #6128 already fixed.
Compare with the already-fixed sibling: crates/zeph-core/src/agent/tool_execution/tier_loop.rs:2282-2404 (apply_tier_results Phase 2, fixed under #6128)
Why
Violates the CLAUDE.md non-blocking contract's Await Discipline rule 5 ("Sequential awaits in loops — evaluate if parallelism or spawning is appropriate") on the agent's hottest hot path.
Only manifests when PreToolUse hooks are configured (opt-in feature), but when active it directly multiplies per-tier latency by tier size, which is unbounded parallelism's whole reason for existing in this loop in the first place.
A fix would mirror apply_tier_results Phase 2: pre-collect the per-idx hook data that doesn't borrow &mut self, build independent futures, and bound them with the same tier semaphore (or a comparable bound) rather than the current unbounded sequential .await chain — while preserving the existing invariant that gate checks for a given idx still run only after that idx's own hook has fired.
Finding
build_tier_call_futuresfiresPreToolUsehooks for each tool call in a tier sequentially, inside the loop that builds the tier's execution futures — before any of the tier's tool executions even start. This is the same class of problem that#6128fixed for thePostToolUseside inapply_tier_results, but the fix was never mirrored to thePreToolUseside.apply_tier_resultsPhase 2 explicitly documents why it parallelizes hook firing (bounded bymax_parallelsemaphore):build_tier_call_futures'sPreToolUsefiring has no such treatment — it awaitsfire_hooks(...)once peridxin a plainforloop before pushing totier_futs:For a tier of N tool calls that all match a
PreToolUsehook (e.g. a subprocess-based guardrail), this addsN × hook_latencyof purely sequential blocking on the agent turn loop — the project's hottest hot path perCLAUDE.md's non-blocking contract — before the tier's already-parallelized tool execution even begins. The ordering constraint that requires each hook to fire before its own call's gate checks (documented at the top of the loop, so the hook always observes the LLM's request even when a gate later intercepts it) does not require sequentiality across differentidxvalues — each call'sPreToolUsefiring is independent of every other call's, exactly like thePostToolUsecase#6128already fixed.Location
crates/zeph-core/src/agent/tool_execution/tier_loop.rs:1997-2056(insidebuild_tier_call_futures, lines 1958-2140)Compare with the already-fixed sibling:
crates/zeph-core/src/agent/tool_execution/tier_loop.rs:2282-2404(apply_tier_resultsPhase 2, fixed under #6128)Why
PreToolUsehooks are configured (opt-in feature), but when active it directly multiplies per-tier latency by tier size, which is unbounded parallelism's whole reason for existing in this loop in the first place.apply_tier_resultsPhase 2: pre-collect the per-idx hook data that doesn't borrow&mut self, build independent futures, and bound them with the same tiersemaphore(or a comparable bound) rather than the current unbounded sequential.awaitchain — while preserving the existing invariant that gate checks for a givenidxstill run only after thatidx's own hook has fired.