mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
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.
297 lines
9.6 KiB
Python
297 lines
9.6 KiB
Python
"""Unit tests for the generic interception-hook dispatcher.
|
|
|
|
These cover the new contract (payload-in/payload-out + HookAborted), the shared
|
|
ordered queue between the legacy and new dialects on the four model/tool points,
|
|
execution-scoped hooks, fail-open exception handling, telemetry, and the no-op
|
|
fast-path overhead budget.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import time
|
|
|
|
from crewai.events.event_bus import crewai_event_bus
|
|
from crewai.events.types.hook_events import HookDispatchedEvent
|
|
from crewai.hooks.dispatch import (
|
|
HookAborted,
|
|
InterceptionPoint,
|
|
clear_all,
|
|
dispatch,
|
|
get_hooks,
|
|
on,
|
|
register,
|
|
register_scoped,
|
|
scoped_hooks,
|
|
unregister as unregister_hook,
|
|
)
|
|
from crewai.hooks.llm_hooks import (
|
|
get_before_llm_call_hooks,
|
|
register_before_llm_call_hook,
|
|
)
|
|
import pytest
|
|
|
|
|
|
@dataclass
|
|
class _Ctx:
|
|
payload: object = None
|
|
tool_name: str | None = None
|
|
agent: object = None
|
|
agent_role: str | None = None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_dispatch_registry():
|
|
"""Ensure every test starts and ends with an empty global registry."""
|
|
clear_all()
|
|
yield
|
|
clear_all()
|
|
|
|
|
|
class TestDispatchContract:
|
|
"""The core payload-in/payload-out + HookAborted contract."""
|
|
|
|
def test_noop_fast_path_returns_context_unchanged(self):
|
|
ctx = _Ctx(payload="original")
|
|
result = dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
|
assert result is ctx
|
|
assert ctx.payload == "original"
|
|
|
|
def test_return_value_replaces_payload(self):
|
|
def double(ctx):
|
|
return ctx.payload * 2
|
|
|
|
register(InterceptionPoint.PRE_MODEL_CALL, double)
|
|
ctx = _Ctx(payload="ab")
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
|
assert ctx.payload == "abab"
|
|
|
|
def test_in_place_mutation_is_honored(self):
|
|
def mutate(ctx):
|
|
ctx.payload.append(1)
|
|
return None
|
|
|
|
register(InterceptionPoint.PRE_MODEL_CALL, mutate)
|
|
ctx = _Ctx(payload=[])
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
|
assert ctx.payload == [1]
|
|
|
|
def test_hooks_run_in_registration_order(self):
|
|
order: list[int] = []
|
|
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.PRE_MODEL_CALL, blocker)
|
|
with pytest.raises(HookAborted) as exc:
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
|
assert exc.value.reason == "nope"
|
|
assert exc.value.source == "policy"
|
|
|
|
def test_ordinary_exception_is_swallowed_and_later_hooks_run(self):
|
|
ran: list[str] = []
|
|
|
|
def boom(ctx):
|
|
ran.append("boom")
|
|
raise ValueError("bug in user hook")
|
|
|
|
def after(ctx):
|
|
ran.append("after")
|
|
|
|
register(InterceptionPoint.PRE_MODEL_CALL, boom)
|
|
register(InterceptionPoint.PRE_MODEL_CALL, after)
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(), verbose=False)
|
|
assert ran == ["boom", "after"]
|
|
|
|
|
|
class TestOnDecorator:
|
|
"""The @on decorator registers and filters like the legacy decorators."""
|
|
|
|
def test_on_registers_global_hook(self):
|
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
|
def hook(ctx):
|
|
return None
|
|
|
|
assert hook in get_hooks(InterceptionPoint.POST_TOOL_CALL)
|
|
|
|
def test_tool_filter_skips_non_matching_tools(self):
|
|
seen: list[str] = []
|
|
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["allowed_tool"])
|
|
def hook(ctx):
|
|
seen.append(ctx.tool_name)
|
|
|
|
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(tool_name="other_tool"))
|
|
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(tool_name="allowed_tool"))
|
|
assert seen == ["allowed_tool"]
|
|
|
|
def test_agent_filter_skips_non_matching_agents(self):
|
|
seen: list[str] = []
|
|
|
|
class _Agent:
|
|
def __init__(self, role):
|
|
self.role = role
|
|
|
|
@on(InterceptionPoint.PRE_MODEL_CALL, agents=["Researcher"])
|
|
def hook(ctx):
|
|
seen.append(ctx.agent.role)
|
|
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(agent=_Agent("Writer")))
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(agent=_Agent("Researcher")))
|
|
assert seen == ["Researcher"]
|
|
|
|
def test_agent_filter_falls_back_to_agent_role(self):
|
|
seen: list[str] = []
|
|
|
|
@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_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):
|
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["allowed_tool"])
|
|
def hook(ctx):
|
|
return None
|
|
|
|
assert len(get_hooks(InterceptionPoint.PRE_TOOL_CALL)) == 1
|
|
assert unregister_hook(InterceptionPoint.PRE_TOOL_CALL, hook) is True
|
|
assert get_hooks(InterceptionPoint.PRE_TOOL_CALL) == []
|
|
|
|
|
|
class TestSharedQueueWithLegacyDialect:
|
|
"""Legacy registrations and @on hooks compose in one ordered queue."""
|
|
|
|
def test_on_and_legacy_share_pre_model_call_queue(self):
|
|
def legacy(ctx):
|
|
return None
|
|
|
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
|
def modern(ctx):
|
|
return None
|
|
|
|
register_before_llm_call_hook(legacy)
|
|
|
|
queue = get_before_llm_call_hooks()
|
|
assert modern in queue
|
|
assert legacy in queue
|
|
# registration order preserved: modern registered before legacy
|
|
assert queue.index(modern) < queue.index(legacy)
|
|
|
|
|
|
class TestScopedHooks:
|
|
"""Execution-scoped hooks run after globals and are discarded on exit."""
|
|
|
|
def test_scoped_runs_after_global_then_cleared(self):
|
|
order: list[str] = []
|
|
register(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("global"))
|
|
|
|
with scoped_hooks():
|
|
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.POST_MODEL_CALL, _Ctx())
|
|
|
|
assert order == ["global", "scoped", "global"]
|
|
|
|
|
|
class TestTelemetry:
|
|
"""dispatch emits a HookDispatchedEvent only when hooks ran."""
|
|
|
|
def test_no_event_on_empty_fast_path(self):
|
|
events: list[HookDispatchedEvent] = []
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(HookDispatchedEvent)
|
|
def _capture(_source, event):
|
|
events.append(event)
|
|
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
|
|
|
assert events == []
|
|
|
|
def test_event_reports_outcome(self):
|
|
events: list[HookDispatchedEvent] = []
|
|
|
|
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: "changed")
|
|
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(HookDispatchedEvent)
|
|
def _capture(_source, event):
|
|
events.append(event)
|
|
|
|
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 == "pre_model_call"
|
|
assert events[0].outcome == "modified"
|
|
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.PRE_MODEL_CALL, 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.PRE_MODEL_CALL, _Ctx())
|
|
crewai_event_bus.flush()
|
|
|
|
assert len(events) == 1
|
|
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"
|
|
|
|
|
|
class TestNoOpOverhead:
|
|
"""The no-op fast path must stay cheap (a single dict lookup)."""
|
|
|
|
def test_noop_dispatch_overhead_is_bounded(self):
|
|
# Relative (not absolute) budget: the no-op fast path is a dict lookup
|
|
# plus a guard, so it should stay within a wide multiple of a bare
|
|
# function call. This catches accidental O(n) regressions without
|
|
# depending on absolute timing on shared CI runners.
|
|
ctx = _Ctx()
|
|
iterations = 100_000
|
|
|
|
def _baseline(_c):
|
|
return _c
|
|
|
|
for _ in range(1000): # warm up both paths
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
|
_baseline(ctx)
|
|
|
|
start = time.perf_counter()
|
|
for _ in range(iterations):
|
|
_baseline(ctx)
|
|
baseline = time.perf_counter() - start
|
|
|
|
start = time.perf_counter()
|
|
for _ in range(iterations):
|
|
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
|
noop = time.perf_counter() - start
|
|
|
|
assert noop < baseline * 50 + 5e-3
|