Skip to content

Add hook system for tool execution lifecycle#702

Closed
iamnbutler wants to merge 1 commit intomainfrom
tasks/gh-iamnbutler-tasks-issue-669--e4f89eea
Closed

Add hook system for tool execution lifecycle#702
iamnbutler wants to merge 1 commit intomainfrom
tasks/gh-iamnbutler-tasks-issue-669--e4f89eea

Conversation

@iamnbutler
Copy link
Copy Markdown
Owner

Summary

  • Adds a ToolHook trait with pre_tool_use, post_tool_use, and post_tool_use_failure callbacks for intercepting tool execution at each stage of the lifecycle
  • Implements HookRegistry to manage ordered hook chains with short-circuit blocking on pre-hooks and output modification on post-hooks
  • Integrates hooks into Session (with add_hook() and builder support) and adds execute_tool_calls_hooked() that wraps the existing concurrent/serial execution pipeline
  • Pre-hooks can block execution (PreHookResult::Block) or modify input (PreHookResult::Continue); post-hooks can modify output, add context, or stop the query loop

Test plan

  • 12 new unit tests covering: empty registry passthrough, blocking dangerous commands, input transformation, logging, context addition, stop-loop, hook chaining order, block-stops-chain, apply_to_result, failure hooks, registry len/is_empty
  • All 69 existing + new tests pass
  • Doc test for the ToolHook trait example compiles and passes
  • Full workspace cargo check passes cleanly

Closes #669

🤖 Generated with Claude Code

Implements a ToolHook trait with pre_tool_use, post_tool_use, and
post_tool_use_failure callbacks that can block execution, modify
inputs/outputs, add context, or stop the query loop. Hooks are
managed via HookRegistry and integrated into Session and the tool
execution pipeline via execute_tool_calls_hooked.

Closes #669

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The diff implements a well-structured hook system for tool execution lifecycle that aligns with the issue requirements. Key observations:

  1. Issue alignment: The implementation covers all three hook points from the spec (PreToolUse, PostToolUse, PostToolUseFailure) with appropriate result types (PreHookResult with Continue/Block, PostHookResult with Continue/AddContext/StopLoop).

  2. Correctness: The trait design is clean - synchronous methods with sensible defaults. The HookRegistry correctly chains hooks in order, with Block short-circuiting the pre-hook chain and StopLoop short-circuiting the post-hook chain. The execute_tool_calls_hooked function properly integrates with the existing partitioning/concurrency logic.

  3. Design decisions: The trait is synchronous (not async as in the original proposal), which is a reasonable simplification for the initial implementation. The hooks are Send + Sync which is correct for concurrent execution.

  4. Integration: Hooks are properly integrated into Session (with add_hook and hook_context methods), SessionBuilder (with hook builder method), and exported from lib.rs. The execute_tool_calls_hooked function is exported alongside the existing execute_tool_calls.

  5. Testing: Comprehensive unit tests cover empty registry passthrough, blocking, input transformation, logging, context addition, stop loop, hook chaining, block-stops-chain behavior, apply_to_result, failure hooks, and registry utilities.

  6. Minor notes: The apply_to_result method only runs post-hooks (not pre-hooks), which is intentional since it's meant to be called after execution. The AddContext variant in run_post_hooks flattens context into the output string, which loses the structured context info but is pragmatic for the current architecture. The optimization to skip to execute_tool_calls when hooks are empty is a nice touch.

  7. No conflicts with other PRs in the merge queue based on the changes touching hooks.rs (new file), lib.rs (re-exports), session.rs (hook field), and tool_exec.rs (new hooked executor).

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The PR implements a well-structured hook system for tool execution lifecycle that aligns with the issue requirements. Key observations:

  1. Issue alignment: The diff implements PreToolUse, PostToolUse, and PostToolUseFailure hooks as specified. The ToolHook trait, PreHookResult/PostHookResult enums, HookRegistry, and ToolUseContext all match the proposed design.

  2. Correctness: The implementation is sound:

    • HookRegistry::run_pre_hooks correctly chains hooks, passing modified input forward, and short-circuits on Block
    • run_post_hooks correctly chains output modifications and short-circuits on StopLoop
    • execute_tool_calls_hooked properly delegates to the non-hooked version when no hooks are registered (optimization)
    • execute_single_hooked correctly sequences pre-hooks → execution → post-hooks
    • The hook trait methods are synchronous (not async as in the proposal), which is a reasonable simplification for the initial implementation
  3. Integration: Hooks are properly integrated into Session (field, add_hook method, hook_context method) and SessionBuilder (.hook() builder method). The execute_tool_calls_hooked function is properly exported from lib.rs.

  4. Tests: Comprehensive test coverage including: empty registry passthrough, blocking dangerous commands, input transformation, logging, context addition, stop loop, multiple hooks chaining, block stops chain, apply_to_result, failure hooks, and registry utility methods.

  5. Minor design note: The apply_to_result method doesn't differentiate behavior between StopLoop and Continue in terms of the returned ToolResult — it just updates the content string in both cases. The StopLoop semantic would need to be handled by the caller. This is acceptable for the foundational implementation since the query loop integration would be a separate concern.

  6. No conflicts with queued PRs. PR Add concurrency-safe tool execution to agent crate #679 (parallel tool execution) is already merged, and this PR correctly builds on that pattern with partition_tool_calls and chunked execution.

The implementation is clean, well-documented with doc comments and examples, and provides a solid foundation for the hook system.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good foundation — the trait design, registry chaining, and concurrency-preserving execute_tool_calls_hooked are all clean. Two issues need attention before this lands.

The PR builds and all tests pass (cargo test --package tasks-agent — 69 tests, all green). However cargo clippy --package tasks-agent -- -D warnings fails with 3 new errors in execute_tool_calls_hooked (redundant .cloned() + two redundant let rebindings in the closure). See inline comment on tool_exec.rs.

The more significant issue is that StopLoop is silently discarded in apply_to_result — it's matched in the same arm as Continue and the signal never reaches callers. execute_tool_calls_hooked returns Vec(ToolResult), so there's currently no path for a hook that returns StopLoop to actually stop the query loop. See inline comment on hooks.rs:212.

There's also a dead match arm for AddContext in apply_to_resultrun_post_hooks folds that variant into Continue internally and never returns it (noted inline).

Full findings

Important

# File Finding
1 tool_exec.rs:132–143 3 clippy errors (redundant_iter_cloned, redundant_locals ×2) block -D warnings
2 hooks.rs:212 StopLoop collapsed into Continue in apply_to_result; signal is lost to all callers

Suggestions

# File Finding
3 hooks.rs:218 AddContext arm in apply_to_result is dead code — run_post_hooks never returns it
4 hooks.rs ToolHook methods are sync-only; hooks can't do async work (may be intentional)
5 session.rs:77–82 hook_context() clones the full metadata HashMap on every call; consider Arc(HashMap) or passing a reference if hooks are called frequently

Build results

cargo test --package tasks-agent     ✅  69 passed, 0 failed
cargo clippy --package tasks-agent   ❌  4 errors (1 pre-existing in chain.rs, 3 new in tool_exec.rs)

References:

Reviewed by PR / Review

Comment on lines +132 to +143
for chunk in batch.calls.chunks(MAX_CONCURRENT) {
let futures: Vec<_> = chunk
.iter()
.cloned()
.map(|tc| {
let hooks = hooks;
let context = context;
let executor = &executor;
async move {
execute_single_hooked(tc, hooks, context, executor).await
}
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPORTANT] Priority: Correctness

cargo clippy --package tasks-agent -- -D warnings fails on this block with three new errors introduced by this PR:

  1. redundant_iter_cloned — clippy flags .iter().cloned() here
  2. redundant_localslet hooks = hooks; rebinds the existing binding
  3. redundant_localslet context = context; rebinds the existing binding

To fix, replace .iter().cloned() with .into_iter() (the chunk is not needed after this), and drop the redundant rebindings:

let futures: Vec<_> = chunk
    .iter()
    .map(|tc| async move {
        execute_single_hooked(tc.clone(), hooks, context, &executor).await
    })
    .collect();

Or restructure to use chunk.to_vec().into_iter() and remove the three redundant lines. Either way, the current code fails a strict clippy run.

Comment thread crates/agent/src/hooks.rs
result.is_error,
context,
) {
PostHookResult::Continue(output) | PostHookResult::StopLoop(output) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPORTANT] Priority: Correctness

StopLoop is collapsed into Continue here, silently discarding the "stop the query loop" signal. Any caller of apply_to_result — including execute_single_hooked and ultimately execute_tool_calls_hooked — receives a plain ToolResult with no way to know a hook requested loop termination. The StopLoop variant's documented contract ("Stop the query loop with this output") is unfulfilled.

The existing test (test_post_hook_stop_loop) only exercises run_post_hooks directly, so it doesn't catch this.

To honour the contract, apply_to_result (or execute_single_hooked) needs to surface the signal. Options:

  • Return (ToolResult, bool /* stop_loop */) from execute_single_hooked/execute_tool_calls_hooked
  • Return a wrapper enum instead of a bare ToolResult
  • Set is_error = true and/or a sentinel value in the result so existing callers can detect stop

Without this fix, registering a StopLoop hook has no observable effect on the query loop.

Comment thread crates/agent/src/hooks.rs
..result
}
}
PostHookResult::AddContext { output, .. } => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Priority: Code Quality

This arm is unreachable. run_post_hooks converts AddContext into a formatted Continue string internally (line 184–187) and only ever returns Continue or StopLoop. The AddContext variant can never escape run_post_hooks, so this match arm is dead code.

It also silently drops the context part (the .. ignore), which would be surprising if the arm were ever reached.

Consider removing this arm — the exhaustive match will then compile away cleanly once run_post_hooks is typed to only return Continue | StopLoop, or add an unreachable!() to make the invariant explicit while you decide whether apply_to_result should have a different signature.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The diff implements a clean hook system for tool execution lifecycle that aligns well with the issue requirements. Key observations:

  1. Issue alignment: The implementation covers all three hook points (PreToolUse, PostToolUse, PostToolUseFailure) as specified. The PreHookResult and PostHookResult enums map to the required capabilities (blocking, input modification, output modification, context addition, loop stopping).

  2. Correctness: The trait design is solid - synchronous hooks (not async as in the proposal, but this is a reasonable simplification since the description doesn't mandate async). The HookRegistry correctly chains hooks in order, with blocking stopping the chain early. The execute_tool_calls_hooked function properly delegates to execute_tool_calls when no hooks are registered (fast path). The execute_single_hooked function correctly sequences pre-hooks → execution → post-hooks.

  3. Completeness: Session integration is done - Session has a hooks field, add_hook() method, hook_context() method, and SessionBuilder supports .hook(). The HookRegistry is properly re-exported from lib.rs. All the proposed use cases (logging, validation, transformation, attribution) are demonstrated in tests.

  4. Minor design note: The trait methods are synchronous rather than async as proposed in the issue. This is actually fine for the stated use cases (logging, validation, path normalization) and avoids complexity. If async hooks are needed later, this can be extended.

  5. Tests: Comprehensive test coverage - 11 test functions covering empty registry passthrough, blocking, input transformation, logging, context addition, stop loop, hook chaining, block stopping chain, apply_to_result, failure hooks, and registry len/is_empty.

  6. No conflicts visible with the queued PRs. The changes touch session.rs, lib.rs, and tool_exec.rs in additive ways that shouldn't conflict with the tool result budgeting (PR Add tool result budgeting to persist large outputs to disk #697) or context compaction (PR Add context compaction via LLM summarization #703) changes.

The one minor concern from the bot review about clippy warnings is noted but not blocking - the code is structurally sound and the implementation is well-designed.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The diff implements a clean hook system for tool execution lifecycle that aligns well with the issue requirements. Key observations:

  1. Issue alignment: The implementation covers PreToolUse (can block or modify input), PostToolUse (can modify output, add context, stop loop), and PostToolUseFailure - matching the Claude Code hook system described in the issue.

  2. Correctness: The trait design is sound - synchronous hooks (reasonable since most hooks are lightweight checks/transforms), proper chaining semantics (block stops chain, AddContext appends and continues, StopLoop short-circuits). The execute_tool_calls_hooked function properly delegates to the non-hooked version when no hooks are registered (good optimization). The execute_single_hooked function correctly sequences pre-hooks → execution → post-hooks.

  3. Completeness: All three hook points are implemented. HookRegistry with chaining is done. Session integration (add_hook, hook_context, SessionBuilder.hook()) is done. The execute_tool_calls_hooked is properly exported. Tests cover: empty registry passthrough, blocking, input transformation, logging, context addition, stop loop, hook chaining order, block-stops-chain, apply_to_result, failure hooks, and registry len/is_empty.

  4. Minor notes: The hooks are synchronous (not async as in the proposal), which is a reasonable simplification for v1 - most hook use cases (validation, logging, transformation) don't need async. The apply_to_result method doesn't run pre-hooks (only post-hooks), which is correct since it's meant to be called after execution. The AddContext variant's context field gets lost in apply_to_result (it only keeps the output), but the context was already folded into the output string by run_post_hooks, so this is consistent.

  5. No conflicts with queued PRs visible. The changes are additive - new module, new fields with defaults, new exports.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The implementation is clean and correct. It delivers the core hook system as specified in the issue:

  1. ToolHook trait with pre_tool_use, post_tool_use, and post_tool_use_failure methods with sensible default implementations.
  2. PreHookResult (Continue/Block) and PostHookResult (Continue/AddContext/StopLoop) enums matching the issue's proposal.
  3. HookRegistry that chains hooks in order, with proper short-circuit on Block (pre) and StopLoop (post).
  4. Session integration - add_hook(), hook_context(), builder pattern support via .hook().
  5. execute_tool_calls_hooked that preserves the existing concurrency model (parallel for safe tools, serial for unsafe) while wrapping each call with pre/post hooks.
  6. Smart optimization: if hooks.is_empty() falls back to the unhook'd path.

The trait is synchronous rather than async as proposed in the issue, which is a reasonable simplification - hooks that need async can use block_on or the trait can be extended later. The tests are comprehensive (13 test cases covering empty registry, blocking, chaining, input transform, context addition, stop loop, failure hooks, apply_to_result).

Minor design note: AddContext in run_post_hooks flattens context into the output string with [Hook context: ...] format, which loses the structured separation. But this is a pragmatic choice for a first implementation and is internally consistent.

No conflicts with queued PRs - PR #679 (parallel tool execution) is the closest dependency, and this PR correctly imports and builds on the same partition_tool_calls/execute_tool_calls functions. The public API additions are all additive and non-breaking.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The diff implements a clean hook system for tool execution lifecycle that aligns well with the issue requirements. Key observations:

  1. Issue alignment: The implementation covers all three hook points (PreToolUse, PostToolUse, PostToolUseFailure) as specified. The PreHookResult and PostHookResult enums map to the described capabilities (blocking, input modification, output modification, context addition, loop stopping).

  2. Correctness: The hook chaining logic is correct - pre-hooks chain input modifications and short-circuit on Block, post-hooks chain output modifications and short-circuit on StopLoop. The execute_tool_calls_hooked function correctly preserves the existing concurrency behavior (parallel for safe tools, serial for unsafe) while wrapping each call with hooks. The fast path when hooks.is_empty() is a nice optimization.

  3. Integration: The hooks are properly integrated into Session (with add_hook, hook_context methods) and SessionBuilder (with hook builder method). The HookRegistry is added as a field to Session with sensible defaults.

  4. Design decisions: Making ToolHook synchronous (not async) is a reasonable simplification for the initial implementation - most hook use cases (logging, validation, transformation) don't need async. The trait has default implementations for all methods, making it easy to implement only what's needed.

  5. Tests: Comprehensive test coverage with 10 tests covering: empty registry passthrough, blocking, input transformation, logging, context addition, stop loop, hook chaining, block stopping chain, apply_to_result, failure hooks, and registry metadata.

  6. Minor note: The AddContext variant in run_post_hooks flattens the context into the output string with [Hook context: ...] formatting, which means subsequent hooks lose the structured separation. This is a design choice that's acceptable for v1 but could be refined later.

  7. Public API: All new types are properly re-exported from lib.rs.

  8. No conflicts with queued PRs - this is additive new functionality in a new module.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Approved

The diff implements a clean hook system for tool execution lifecycle that aligns well with the issue requirements. Key observations:

  1. Issue alignment: The implementation covers PreToolUse (block/modify input), PostToolUse (modify output/add context/stop loop), and PostToolUseFailure hooks as specified. The trait design, enum results, and registry pattern all match the proposal.

  2. Correctness: The hook chaining logic is correct — pre-hooks run in order and short-circuit on Block, post-hooks chain output through each hook and short-circuit on StopLoop. The execute_tool_calls_hooked function correctly preserves the concurrency semantics from execute_tool_calls (parallel for safe tools, serial for unsafe). The fast path (hooks.is_empty()) avoids overhead when no hooks are registered.

  3. Design decisions: The trait methods are synchronous (not async as in the proposal), which is a reasonable simplification for the initial implementation — hooks like logging, validation, and transformation don't typically need async. This can be extended later if needed.

  4. Integration: Session properly gets a hooks field and add_hook/hook_context methods. SessionBuilder supports .hook(). The execute_tool_calls_hooked is properly exported from lib.rs.

  5. Tests: Comprehensive unit tests covering: empty registry passthrough, blocking hooks, input transformation, logging, context addition, stop loop, hook chaining order, block stopping the chain, apply_to_result, failure hooks, and registry len/is_empty.

  6. Minor note: The AddContext variant in run_post_hooks flattens context into the output string with a format string, which means downstream hooks see the appended context. In apply_to_result, the AddContext branch discards the separate context field and just uses the output — this is consistent since run_post_hooks already merged them. The StopLoop handling in apply_to_result treats it same as Continue (just uses the output), which is fine since the stop-loop semantic would be handled at a higher level.

  7. No conflicts with queued PRs visible — this adds a new module and extends existing structures additively.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Orchestrator Evaluation: Rejected

The PR has merge conflicts (mergeable: No) and cannot be merged in its current state. Beyond that, the implementation is solid but has a few issues: 1) The trait methods are synchronous (not async as proposed in the issue), which limits hook implementations that need I/O (e.g., rate limiting with external state, logging to files/network). The issue proposal explicitly showed async methods. 2) The apply_to_result method only runs post-hooks but the name suggests it handles the full lifecycle - it doesn't run pre-hooks, which could be confusing. 3) The AddContext variant in PostHookResult has its context field consumed/flattened into the output string in run_post_hooks (via format!), losing the structured separation between output and context that the type system promises. Downstream code receiving PostHookResult::Continue can't distinguish 'original output' from 'output + appended context'. 4) The StopLoop variant is handled in run_post_hooks but execute_single_hooked calls apply_to_result which treats StopLoop the same as Continue - the stop-loop signal is lost and never propagated to the caller. There's no mechanism for the caller of execute_tool_calls_hooked to know that a hook requested stopping the query loop. 5) The hooks in execute_tool_calls_hooked run pre-hooks synchronously (blocking) in what should be an async context, which is fine for now but inconsistent with the async executor pattern. The merge conflicts alone are a blocking issue.


Feedback for agent:

  1. Merge conflicts: The PR cannot be merged due to conflicts. These need to be resolved first.

  2. StopLoop signal is silently dropped: In apply_to_result, PostHookResult::StopLoop(output) is handled identically to PostHookResult::Continue(output) - the signal to stop the query loop is lost. execute_tool_calls_hooked returns Vec<ToolResult> with no way to communicate that a hook requested stopping. You need either a richer return type or a side-channel (e.g., a flag in the result) to propagate this.

  3. AddContext loses structure: In run_post_hooks, the AddContext variant's context is concatenated into the output string. This means subsequent hooks and callers can't distinguish structured context from output. Consider preserving it as a separate field.

  4. Sync vs Async: The issue proposal specified async hook methods. The current synchronous implementation prevents hooks that need I/O (rate limiting with shared state, network logging, permission prompts). Consider making the trait methods async or providing an async variant.

  5. Minor: apply_to_result is a misleading name since it only applies post-hooks, not the full lifecycle.

@iamnbutler
Copy link
Copy Markdown
Owner Author

Closing duplicate - newer PR #706 exists

@iamnbutler iamnbutler closed this Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agent: Hook system for tool execution lifecycle

1 participant