Skip to content

feat: add generic interception-hook dispatcher#6516

Merged
lucasgomide merged 6 commits into
mainfrom
luzk/hooks-dispatcher
Jul 14, 2026
Merged

feat: add generic interception-hook dispatcher#6516
lucasgomide merged 6 commits into
mainfrom
luzk/hooks-dispatcher

Conversation

@lucasgomide

@lucasgomide lucasgomide commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

CrewAI only exposed two interception points (LLM and tool calls), with no way to abort with a reason or observe hook activity. This introduces a single dispatcher where a hook receives a typed context and may mutate or replace its payload or raise HookAborted(reason, source); the InterceptionPoint enum starts with the four model/tool boundaries and grows as later PRs wire new seams. The existing hooks become adapters over it, so their decorators and return False semantics keep working unchanged. Every dispatch that runs hooks also emits a HookDispatchedEvent with the outcome, hook count, duration, and abort metadata.


Note

Medium Risk
Changes how every LLM and tool call runs hooks across multiple execution paths; semantics are mostly preserved but fail-open, scoped-hook coverage, and blocked-tool after-hooks are intentional behavior shifts that could affect monitoring and guardrails.

Overview
Introduces a central hook dispatcher (dispatch, run_hooks, @on, HookAborted, InterceptionPoint) so model- and tool-call interception share one ordered queue per point, with execution-scoped hooks (scoped_hooks / contextvars) running after globals.

Legacy hooks stay compatible by aliasing register_* lists to the dispatcher and mapping return False to abort via point-specific reducers. Call sites in executors, agent_utils, tool_utils, and direct BaseLLM paths are refactored to run_before_tool_call_hooks / run_after_tool_call_hooks and dispatcher-driven LLM setup instead of ad-hoc hook loops.

Adds HookDispatchedEvent telemetry (outcome, duration, abort metadata) when hooks actually run, and CrewBase registration for @on(InterceptionPoint.*) methods that were previously ignored.

Behavior fixes bundled with the migration: hook exceptions are fail-open per hook (later hooks still run); scoped hooks apply on executor LLM seams and agent-less LLM calls; ReAct tool paths run post-tool hooks even when pre-tool blocks, matching native paths.

Reviewed by Cursor Bugbot for commit 8a24330. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a centralized interception-hook dispatcher with global and scoped registration, reducers, filtering, abort handling, telemetry, legacy LLM/tool adapters, crew hook registration, executor integration, and expanded tests.

Changes

Hook dispatch integration

Layer / File(s) Summary
Interception contracts and public API
lib/crewai/src/crewai/hooks/dispatch.py, lib/crewai/src/crewai/hooks/__init__.py, lib/crewai/src/crewai/events/types/hook_events.py
Defines interception points, abort handling, dispatcher types, telemetry events, and public hook exports.
Global and scoped dispatch execution
lib/crewai/src/crewai/hooks/dispatch.py
Adds ordered global/scoped hook resolution, reducers, filtering, exception handling, telemetry, and the on decorator.
Legacy LLM and tool hook adapters
lib/crewai/src/crewai/hooks/llm_hooks.py, lib/crewai/src/crewai/hooks/tool_hooks.py
Connects legacy hook registries and return conventions to centralized interception points and dispatch runners.
LLM and tool execution integration
lib/crewai/src/crewai/agents/crew_agent_executor.py, lib/crewai/src/crewai/experimental/agent_executor.py, lib/crewai/src/crewai/llms/base_llm.py, lib/crewai/src/crewai/utilities/*
Replaces inline hook iteration with centralized runners for native tool calls, tool utilities, and direct or agent LLM calls.
Crew decorated-hook registration
lib/crewai/src/crewai/project/crew_base.py
Registers interception-decorated crew methods with bound instances, filters, and registration tracking.
Dispatcher and integration tests
lib/crewai/tests/hooks/test_dispatch.py, lib/crewai/tests/hooks/test_crew_scoped_hooks.py, lib/crewai/tests/hooks/test_llm_hooks.py
Tests dispatch contracts, ordering, filtering, aborts, scoped hooks, telemetry, crew method binding, direct LLM hooks, legacy queue sharing, and no-op performance.

Sequence Diagram(s)

sequenceDiagram
  participant Executor
  participant HookDispatcher
  participant Hooks
  participant HookDispatchedEvent
  Executor->>HookDispatcher: dispatch interception point and context
  HookDispatcher->>Hooks: resolve and run registered hooks
  Hooks-->>HookDispatcher: proceed, modified context, or abort
  HookDispatcher->>HookDispatchedEvent: emit dispatch metrics
  HookDispatcher-->>Executor: return updated context or blocked result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: a generic interception-hook dispatcher was added.
Description check ✅ Passed The description accurately describes the dispatcher, HookAborted, telemetry, and legacy adapter behavior.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch luzk/hooks-dispatcher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread lib/crewai/src/crewai/hooks/tool_hooks.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
lib/crewai/tests/hooks/test_dispatch.py (1)

187-215: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing telemetry test for the HookAborted path.

run_hooks explicitly documents that telemetry is emitted in the finally block before HookAborted propagates (with outcome="aborted", abort_reason, and abort_source populated). No test in TestTelemetry covers this path. Add a test that registers an aborting hook, captures events, and asserts the event reflects outcome == "aborted" with the correct reason and source.

♻️ Suggested test for abort telemetry
     assert events[0].hook_count == 1


+    def test_event_reports_abort_outcome(self):
+        events: list[HookDispatchedEvent] = []
+
+        def blocker(ctx):
+            raise HookAborted(reason="blocked", source="policy")
+
+        register(InterceptionPoint.INPUT, blocker)
+
+        with crewai_event_bus.scoped_handlers():
+
+            `@crewai_event_bus.on`(HookDispatchedEvent)
+            def _capture(_source, event):
+                events.append(event)
+
+            with pytest.raises(HookAborted):
+                dispatch(InterceptionPoint.INPUT, _Ctx())
+
+        assert len(events) == 1
+        assert events[0].outcome == "aborted"
+        assert events[0].abort_reason == "blocked"
+        assert events[0].abort_source == "policy"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/tests/hooks/test_dispatch.py` around lines 187 - 215, Add a test
in TestTelemetry alongside test_event_reports_outcome that registers a hook
raising HookAborted with a known reason and source, captures HookDispatchedEvent
through scoped_handlers, and asserts dispatch propagates the abort while
emitting one event with interception_point "input", outcome "aborted", and
matching abort_reason and abort_source.
lib/crewai/src/crewai/hooks/dispatch.py (2)

237-243: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

_default_reducer reports "modified" even when payload wasn't applied.

If result is not None but ctx has no payload attribute, nothing is mutated yet the function still returns True, causing dispatch to record an inaccurate "modified" telemetry outcome. Return True only when the payload is actually set.

Proposed tweak
 def _default_reducer(ctx: Any, result: Any) -> bool:
     """Default payload semantics: a non-None return replaces ``ctx.payload``."""
-    if result is not None:
-        if hasattr(ctx, "payload"):
-            ctx.payload = result
-        return True
+    if result is not None and hasattr(ctx, "payload"):
+        ctx.payload = result
+        return True
     return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/hooks/dispatch.py` around lines 237 - 243, Update
_default_reducer so it returns True only when result is non-None and ctx has a
payload attribute that is successfully assigned; return False when no payload
attribute exists or result is None, preserving the existing payload replacement
behavior.

398-416: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

on() registers the wrapped hook but returns the original func, breaking unregister.

When agents/tools filters are present, register(point, hook) stores the filtered wrapper, yet the decorator returns func. A later unregister(point, func) (or unregister_hook) will fail to remove the registered wrapper since it never matches the stored callable. Consider stashing the registered wrapper on func (e.g. func._registered_hook = hook) so unregistration can resolve it, or have unregister account for the wrapper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/hooks/dispatch.py` around lines 398 - 416, The
decorator’s filtered path registers a wrapped hook but returns the original
callable, so unregister cannot match the stored registration. Update decorator
to retain the exact hook passed to register in a discoverable attribute on func,
and update the corresponding unregister logic to resolve and remove that
registered hook while preserving unfiltered and method behavior.
lib/crewai/src/crewai/hooks/contexts.py (1)

21-31: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Filter wrapper ignores agent_role. The base context provides both agent and agent_role, but _wrap_with_filters in dispatch.py only inspects agent.role. For contexts where agent is None but agent_role is set (e.g. flow seams), the agents= filter won't apply. Consider having the filter fall back to agent_role for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/hooks/contexts.py` around lines 21 - 31, The agent
filter in _wrap_with_filters should use InterceptionContext.agent_role when
context.agent is absent, while preserving the existing agent.role behavior when
an agent exists. Update the agent-matching logic in dispatch.py so agents=
filters apply consistently to contexts that provide only agent_role.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/crewai/src/crewai/utilities/tool_utils.py`:
- Around line 103-107: Update both blocked-call branches in
aexecute_tool_and_check_finality and execute_tool_and_check_finality to invoke
run_after_tool_call_hooks before returning the blocked ToolResult. Preserve the
existing blocked message and return behavior while matching the native tool-call
paths’ POST_TOOL_CALL handling at both referenced locations in
lib/crewai/src/crewai/utilities/tool_utils.py (103-107 and 206-210).

In `@lib/crewai/tests/hooks/test_dispatch.py`:
- Around line 221-229: Make test_noop_dispatch_overhead_budget non-gating by
marking it with the repository’s established slow or opt-in pytest marker, or
replace its absolute 5µs assertion with a relative baseline comparison against a
bare function call. Preserve validation that no-op dispatch overhead remains
bounded while avoiding dependence on absolute CI timing.

---

Nitpick comments:
In `@lib/crewai/src/crewai/hooks/contexts.py`:
- Around line 21-31: The agent filter in _wrap_with_filters should use
InterceptionContext.agent_role when context.agent is absent, while preserving
the existing agent.role behavior when an agent exists. Update the agent-matching
logic in dispatch.py so agents= filters apply consistently to contexts that
provide only agent_role.

In `@lib/crewai/src/crewai/hooks/dispatch.py`:
- Around line 237-243: Update _default_reducer so it returns True only when
result is non-None and ctx has a payload attribute that is successfully
assigned; return False when no payload attribute exists or result is None,
preserving the existing payload replacement behavior.
- Around line 398-416: The decorator’s filtered path registers a wrapped hook
but returns the original callable, so unregister cannot match the stored
registration. Update decorator to retain the exact hook passed to register in a
discoverable attribute on func, and update the corresponding unregister logic to
resolve and remove that registered hook while preserving unfiltered and method
behavior.

In `@lib/crewai/tests/hooks/test_dispatch.py`:
- Around line 187-215: Add a test in TestTelemetry alongside
test_event_reports_outcome that registers a hook raising HookAborted with a
known reason and source, captures HookDispatchedEvent through scoped_handlers,
and asserts dispatch propagates the abort while emitting one event with
interception_point "input", outcome "aborted", and matching abort_reason and
abort_source.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aaeefb79-630e-47e9-83ba-8fe10cf99f7b

📥 Commits

Reviewing files that changed from the base of the PR and between fb8e93b and 8bc4fa2.

📒 Files selected for processing (12)
  • lib/crewai/src/crewai/agents/crew_agent_executor.py
  • lib/crewai/src/crewai/events/types/hook_events.py
  • lib/crewai/src/crewai/experimental/agent_executor.py
  • lib/crewai/src/crewai/hooks/__init__.py
  • lib/crewai/src/crewai/hooks/contexts.py
  • lib/crewai/src/crewai/hooks/dispatch.py
  • lib/crewai/src/crewai/hooks/llm_hooks.py
  • lib/crewai/src/crewai/hooks/tool_hooks.py
  • lib/crewai/src/crewai/llms/base_llm.py
  • lib/crewai/src/crewai/utilities/agent_utils.py
  • lib/crewai/src/crewai/utilities/tool_utils.py
  • lib/crewai/tests/hooks/test_dispatch.py

Comment thread lib/crewai/src/crewai/utilities/tool_utils.py Outdated
Comment thread lib/crewai/tests/hooks/test_dispatch.py Outdated
@lucasgomide lucasgomide force-pushed the luzk/hooks-dispatcher branch from 8bc4fa2 to a85e100 Compare July 11, 2026 17:16
Comment thread lib/crewai/src/crewai/llms/base_llm.py Outdated
Comment thread lib/crewai/src/crewai/hooks/dispatch.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 730f100. Configure here.

Comment thread lib/crewai/src/crewai/hooks/dispatch.py
Comment thread lib/crewai/src/crewai/utilities/agent_utils.py
Comment thread lib/crewai/src/crewai/hooks/__init__.py
Comment thread lib/crewai/tests/hooks/test_crew_scoped_hooks.py
Introduces `crewai/hooks/dispatch.py` as a single engine behind every
interception point: a hook receives a typed context, may mutate or replace
its `payload`, or raise `HookAborted(reason, source)` to stop the operation.
The full `InterceptionPoint` catalog is frozen from day zero, with global and
contextvar-scoped registries, an `@on` decorator, a no-op fast path, and a
`HookDispatchedEvent` for telemetry. The four existing `before/after_llm_call`
and `before/after_tool_call` hooks become adapters over the dispatcher, so the
legacy dialect and `return False` semantics keep working unchanged while
gaining the new contract.
Corrects several dispatcher edge cases surfaced in review. `_default_reducer`
now reports a modification only when a `payload` is actually applied, the
`agents=` filter falls back to `agent_role` for contexts without an `agent`
object, and `unregister` resolves the filter wrapper stashed by `on` so a
filtered hook can be removed. The tool-hook runners honor the executing
agent's `verbose` flag instead of silently swallowing hook errors, and the
ReAct tool path now runs `POST_TOOL_CALL` on blocked calls to match the
native paths. Also adds abort-telemetry coverage and replaces the flaky
absolute no-op timing budget with a relative one.
…hods

Direct agent-less LLM calls short-circuited on the empty global hook list,
so hooks registered only for the current `scoped_hooks()` context never
ran; the direct-call helpers now defer to `dispatch`, which resolves
scoped hooks behind its own no-op fast path. `CrewBase` likewise only
scanned the legacy `is_*_hook` markers, so `@on(InterceptionPoint.X)`
methods were silently dropped — it now registers them on the dispatcher
with filters applied and `self` bound. Also tightens result typing across
the tool-call seams so `mypy` stays green.
The dispatcher only fires the model- and tool-call boundaries, so
`InterceptionPoint` now lists just those four rather than the full future
catalog. New points are introduced alongside the seams that dispatch them,
keeping every layer free of enum members with no live consumer. The
dispatcher unit tests that borrowed unused points as generic examples are
remapped onto the four kept points.
The dispatcher swallows a hook's exception per hook rather than around the
whole loop, so one buggy hook no longer silently skips every hook registered
after it. These seam-level tests pin that behavior through
`_setup_before_llm_call_hooks` and `run_before/after_tool_call_hooks`, and
confirm an intentional `return False` block still short-circuits later hooks.
`_setup_before/after_llm_call_hooks` only ran the executor's snapshot
hook lists, so hooks registered via `scoped_hooks()` never fired on
`PRE/POST_MODEL_CALL` during normal agent execution, while the tool
seams (which go through `dispatch`) merged them. The seams now append
the current scope's hooks after the snapshot via `get_scoped_hooks`,
matching dispatch's global-then-scoped ordering, and a scoped-only
registration no longer short-circuits the seam.
@lucasgomide lucasgomide force-pushed the luzk/hooks-dispatcher branch from 0471ad0 to 8a24330 Compare July 14, 2026 13:53
@lucasgomide lucasgomide merged commit 7d21283 into main Jul 14, 2026
54 of 55 checks passed
@lucasgomide lucasgomide deleted the luzk/hooks-dispatcher branch July 14, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants