From c616f14098eb9c28274459f9eedffe10ec94bd07 Mon Sep 17 00:00:00 2001 From: Lucas Gomide Date: Mon, 13 Jul 2026 15:12:40 -0300 Subject: [PATCH] refactor: scope InterceptionPoint to the points this layer wires 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. --- lib/crewai/src/crewai/hooks/dispatch.py | 36 ++------------ lib/crewai/tests/hooks/test_dispatch.py | 62 ++++++++++++------------- 2 files changed, 36 insertions(+), 62 deletions(-) diff --git a/lib/crewai/src/crewai/hooks/dispatch.py b/lib/crewai/src/crewai/hooks/dispatch.py index 686d8bdc2..343dcc428 100644 --- a/lib/crewai/src/crewai/hooks/dispatch.py +++ b/lib/crewai/src/crewai/hooks/dispatch.py @@ -38,46 +38,20 @@ from crewai.utilities.string_utils import sanitize_tool_name class InterceptionPoint(str, Enum): - """Catalog of every interception point in the framework. + """Interception points wired by this layer. - The full catalog is frozen from day zero. Points without a live consumer - seam yet (``FILE_ACCESS``, ``ARTIFACT_OUTPUT``) can still be registered - against; dispatch for them is simply never triggered, which is the same - semantics as any point with no hooks. + New points are introduced alongside the seams that dispatch them, so the + enum only ever lists points with a live consumer. This layer ships the + model- and tool-call boundaries, which back the legacy + ``before/after_llm_call`` and ``before/after_tool_call`` hooks. """ - # Execution-level boundaries - EXECUTION_START = "execution_start" - INPUT = "input" - OUTPUT = "output" - EXECUTION_END = "execution_end" - # Model / tool boundaries (legacy-compatible) PRE_MODEL_CALL = "pre_model_call" POST_MODEL_CALL = "post_model_call" PRE_TOOL_CALL = "pre_tool_call" POST_TOOL_CALL = "post_tool_call" - # Step & agent points - PRE_STEP = "pre_step" - POST_STEP = "post_step" - TOOL_SELECTION = "tool_selection" - PRE_DELEGATION = "pre_delegation" - RETRY_ATTEMPT = "retry_attempt" - - # Subsystem points - MEMORY_WRITE = "memory_write" - MEMORY_READ = "memory_read" - KNOWLEDGE_RETRIEVAL = "knowledge_retrieval" - PRE_CODE_EXECUTION = "pre_code_execution" - MCP_CONNECT = "mcp_connect" - FILE_ACCESS = "file_access" - ARTIFACT_OUTPUT = "artifact_output" - - # Flow-specific points - FLOW_TRANSITION = "flow_transition" - ROUTER_DECISION = "router_decision" - class HookAborted(Exception): # noqa: N818 - public contract name from OSS-86 """Raised by a hook (or a legacy adapter) to abort the intercepted operation. diff --git a/lib/crewai/tests/hooks/test_dispatch.py b/lib/crewai/tests/hooks/test_dispatch.py index f4151f73e..9aa5d5912 100644 --- a/lib/crewai/tests/hooks/test_dispatch.py +++ b/lib/crewai/tests/hooks/test_dispatch.py @@ -53,7 +53,7 @@ class TestDispatchContract: def test_noop_fast_path_returns_context_unchanged(self): ctx = _Ctx(payload="original") - result = dispatch(InterceptionPoint.INPUT, ctx) + result = dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx) assert result is ctx assert ctx.payload == "original" @@ -61,9 +61,9 @@ class TestDispatchContract: def double(ctx): return ctx.payload * 2 - register(InterceptionPoint.INPUT, double) + register(InterceptionPoint.PRE_MODEL_CALL, double) ctx = _Ctx(payload="ab") - dispatch(InterceptionPoint.INPUT, ctx) + dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx) assert ctx.payload == "abab" def test_in_place_mutation_is_honored(self): @@ -71,25 +71,25 @@ class TestDispatchContract: ctx.payload.append(1) return None - register(InterceptionPoint.INPUT, mutate) + register(InterceptionPoint.PRE_MODEL_CALL, mutate) ctx = _Ctx(payload=[]) - dispatch(InterceptionPoint.INPUT, ctx) + dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx) assert ctx.payload == [1] def test_hooks_run_in_registration_order(self): order: list[int] = [] - register(InterceptionPoint.INPUT, lambda ctx: order.append(1)) - register(InterceptionPoint.INPUT, lambda ctx: order.append(2)) - dispatch(InterceptionPoint.INPUT, _Ctx()) + register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: order.append(1)) + register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: order.append(2)) + dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx()) assert order == [1, 2] def test_hook_aborted_propagates_with_reason_and_source(self): def blocker(ctx): raise HookAborted(reason="nope", source="policy") - register(InterceptionPoint.INPUT, blocker) + register(InterceptionPoint.PRE_MODEL_CALL, blocker) with pytest.raises(HookAborted) as exc: - dispatch(InterceptionPoint.INPUT, _Ctx()) + dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx()) assert exc.value.reason == "nope" assert exc.value.source == "policy" @@ -103,9 +103,9 @@ class TestDispatchContract: def after(ctx): ran.append("after") - register(InterceptionPoint.INPUT, boom) - register(InterceptionPoint.INPUT, after) - dispatch(InterceptionPoint.INPUT, _Ctx(), verbose=False) + register(InterceptionPoint.PRE_MODEL_CALL, boom) + register(InterceptionPoint.PRE_MODEL_CALL, after) + dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(), verbose=False) assert ran == ["boom", "after"] @@ -113,11 +113,11 @@ class TestOnDecorator: """The @on decorator registers and filters like the legacy decorators.""" def test_on_registers_global_hook(self): - @on(InterceptionPoint.MEMORY_WRITE) + @on(InterceptionPoint.POST_TOOL_CALL) def hook(ctx): return None - assert hook in get_hooks(InterceptionPoint.MEMORY_WRITE) + assert hook in get_hooks(InterceptionPoint.POST_TOOL_CALL) def test_tool_filter_skips_non_matching_tools(self): seen: list[str] = [] @@ -148,13 +148,13 @@ class TestOnDecorator: def test_agent_filter_falls_back_to_agent_role(self): seen: list[str] = [] - @on(InterceptionPoint.PRE_STEP, agents=["Researcher"]) + @on(InterceptionPoint.PRE_TOOL_CALL, agents=["Researcher"]) def hook(ctx): seen.append(ctx.agent_role) # No agent object, only the agent_role string (e.g. flow seams). - dispatch(InterceptionPoint.PRE_STEP, _Ctx(agent_role="Writer")) - dispatch(InterceptionPoint.PRE_STEP, _Ctx(agent_role="Researcher")) + dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(agent_role="Writer")) + dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(agent_role="Researcher")) assert seen == ["Researcher"] def test_unregister_resolves_filtered_wrapper(self): @@ -192,14 +192,14 @@ class TestScopedHooks: def test_scoped_runs_after_global_then_cleared(self): order: list[str] = [] - register(InterceptionPoint.OUTPUT, lambda ctx: order.append("global")) + register(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("global")) with scoped_hooks(): - register_scoped(InterceptionPoint.OUTPUT, lambda ctx: order.append("scoped")) - dispatch(InterceptionPoint.OUTPUT, _Ctx()) + register_scoped(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("scoped")) + dispatch(InterceptionPoint.POST_MODEL_CALL, _Ctx()) # outside the scope the scoped hook is gone - dispatch(InterceptionPoint.OUTPUT, _Ctx()) + dispatch(InterceptionPoint.POST_MODEL_CALL, _Ctx()) assert order == ["global", "scoped", "global"] @@ -215,14 +215,14 @@ class TestTelemetry: def _capture(_source, event): events.append(event) - dispatch(InterceptionPoint.INPUT, _Ctx()) + dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx()) assert events == [] def test_event_reports_outcome(self): events: list[HookDispatchedEvent] = [] - register(InterceptionPoint.INPUT, lambda ctx: "changed") + register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: "changed") with crewai_event_bus.scoped_handlers(): @@ -230,13 +230,13 @@ class TestTelemetry: def _capture(_source, event): events.append(event) - dispatch(InterceptionPoint.INPUT, _Ctx()) + dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx()) # Telemetry handlers run on the bus's thread pool; flush so the # assertion doesn't race the emit. crewai_event_bus.flush() assert len(events) == 1 - assert events[0].interception_point == "input" + assert events[0].interception_point == "pre_model_call" assert events[0].outcome == "modified" assert events[0].hook_count == 1 @@ -246,7 +246,7 @@ class TestTelemetry: def blocker(ctx): raise HookAborted(reason="blocked", source="policy") - register(InterceptionPoint.INPUT, blocker) + register(InterceptionPoint.PRE_MODEL_CALL, blocker) with crewai_event_bus.scoped_handlers(): @@ -255,11 +255,11 @@ class TestTelemetry: events.append(event) with pytest.raises(HookAborted): - dispatch(InterceptionPoint.INPUT, _Ctx()) + dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx()) crewai_event_bus.flush() assert len(events) == 1 - assert events[0].interception_point == "input" + assert events[0].interception_point == "pre_model_call" assert events[0].outcome == "aborted" assert events[0].abort_reason == "blocked" assert events[0].abort_source == "policy" @@ -280,7 +280,7 @@ class TestNoOpOverhead: return _c for _ in range(1000): # warm up both paths - dispatch(InterceptionPoint.INPUT, ctx) + dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx) _baseline(ctx) start = time.perf_counter() @@ -290,7 +290,7 @@ class TestNoOpOverhead: start = time.perf_counter() for _ in range(iterations): - dispatch(InterceptionPoint.INPUT, ctx) + dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx) noop = time.perf_counter() - start assert noop < baseline * 50 + 5e-3