Skip to content

feat: add generic interception-hook dispatcher#6516

Open
lucasgomide wants to merge 2 commits into
mainfrom
luzk/hooks-dispatcher
Open

feat: add generic interception-hook dispatcher#6516
lucasgomide wants to merge 2 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), each a bespoke module, with no way to abort with a reason or intercept the rest of the run. This introduces a single dispatcher where a hook receives a typed context and may mutate or replace its payload or raise HookAborted(reason, source), freezing the full InterceptionPoint catalog from day zero. The four existing hooks become adapters over it, so their decorators and return False semantics keep working unchanged. First of three stacked PRs for the OSS-86 interception-hook catalog.


Note

Medium Risk
Centralizes all LLM/tool interception on one dispatcher; behavior should stay compatible via reducers, but hook ordering, fail-open errors, and the new post-hook-on-block path in tool_utils affect every tool/LLM execution path.

Overview
Introduces crewai.hooks.dispatch as the shared engine for interception: a frozen InterceptionPoint catalog, HookAborted, global + contextvars-scoped registration, dispatch / run_hooks, and @on(...) with optional agent/tool filters.

Legacy LLM and tool hook lists are aliased to the dispatcher’s global queues so existing register_* / decorators and @on(PRE_MODEL_CALL) hooks run in one order. Point-specific reducers preserve return False blocking and in-place message / result mutation.

Call sites (CrewAgentExecutor, experimental executor, agent_utils, tool_utils, base_llm) replace inline hook loops with run_before_tool_call_hooks / run_after_tool_call_hooks and dispatcher-backed LLM paths. HookDispatchedEvent telemetry fires only when hooks actually run.

tool_utils now runs post-tool hooks even when a before-hook blocks, aligning with native tool paths. Unit tests cover contract, shared queue, scoping, telemetry, and no-op overhead.

Reviewed by Cursor Bugbot for commit f7bd240. 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

📝 Walkthrough

Walkthrough

The PR adds a centralized interception-hook dispatcher with global and scoped registration, reducers, filtering, abort handling, telemetry, legacy LLM/tool adapters, executor integration, and dispatcher 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.
Executor and utility 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.
Dispatcher behavior tests
lib/crewai/tests/hooks/test_dispatch.py
Tests dispatch contracts, ordering, filtering, aborts, scoped hooks, telemetry, 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 53.85% 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: adding a generic interception-hook dispatcher.
Description check ✅ Passed The description matches the changeset and accurately describes the dispatcher, adapters, and compatibility 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
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.
@lucasgomide lucasgomide force-pushed the luzk/hooks-dispatcher branch from 8bc4fa2 to a85e100 Compare July 11, 2026 17:16
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.

@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 2 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 f7bd240. Configure here.


before_hooks = get_before_llm_call_hooks()
if not before_hooks:
if not get_before_llm_call_hooks():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scoped hooks skipped on direct LLM

Medium Severity

Direct LLM hook invocation still short-circuits when the global pre_model_call / post_model_call lists are empty, so execution-scoped hooks registered via scoped_hooks never run on that path even though dispatch would resolve them.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f7bd240. Configure here.

# can resolve the filter wrapper.
func._registered_hook = hook # type: ignore[attr-defined]

return func

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CrewBase @on methods never register

Medium Severity

The @on decorator skips global registration for methods with a self parameter and only sets _interception_point, but CrewBase still registers crew hooks by legacy is_*_hook markers, so those methods never run.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f7bd240. Configure here.

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.

1 participant