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.
This commit is contained in:
Lucas Gomide
2026-07-13 15:12:40 -03:00
parent 730f100ce7
commit c616f14098
2 changed files with 36 additions and 62 deletions

View File

@@ -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.

View File

@@ -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