Skip to content

PreToolUse hooks fire sequentially per tier, unlike the already-fixed PostToolUse twin (#6128) #6259

Description

@bug-ops

Finding

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 (tier_local_idx, &idx) in tier_indices.iter().enumerate() {
    ...
    let pre_hooks = self.services.session.hooks_config.pre_tool_use.clone();
    if !pre_hooks.is_empty() {
        let matched: Vec<&zeph_config::HookDef> =
            zeph_subagent::matching_hooks(&pre_hooks, tc.name.as_str());
        if !matched.is_empty() {
            ...
            if let Err(e) = zeph_subagent::hooks::fire_hooks(&owned, &env, mcp, None)
                .instrument(...)
                .await
            { ... }
        }
    }
    ...
    tier_futs.push(self.make_exec_future(idx, tc, call, semaphore));
}

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.

Location

crates/zeph-core/src/agent/tool_execution/tier_loop.rs:1997-2056 (inside build_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_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.
  • Exact same defect class as tier_loop: apply_tier_results serializes RuntimeLayer/PostToolUse hooks per tool result within a tier #6128, left unfixed on the mirrored code path — an asymmetric fix.
  • 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.

Metadata

Metadata

Assignees

Labels

P2High value, medium complexityarchArchitecture and designarchitectureArchitecture improvementsperformancePerformance improvementstech-debtTechnical debt

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions