mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
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.
297 lines
9.4 KiB
Python
297 lines
9.4 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.INPUT, 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.INPUT, double)
|
|
ctx = _Ctx(payload="ab")
|
|
dispatch(InterceptionPoint.INPUT, ctx)
|
|
assert ctx.payload == "abab"
|
|
|
|
def test_in_place_mutation_is_honored(self):
|
|
def mutate(ctx):
|
|
ctx.payload.append(1)
|
|
return None
|
|
|
|
register(InterceptionPoint.INPUT, mutate)
|
|
ctx = _Ctx(payload=[])
|
|
dispatch(InterceptionPoint.INPUT, 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())
|
|
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)
|
|
with pytest.raises(HookAborted) as exc:
|
|
dispatch(InterceptionPoint.INPUT, _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.INPUT, boom)
|
|
register(InterceptionPoint.INPUT, after)
|
|
dispatch(InterceptionPoint.INPUT, _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.MEMORY_WRITE)
|
|
def hook(ctx):
|
|
return None
|
|
|
|
assert hook in get_hooks(InterceptionPoint.MEMORY_WRITE)
|
|
|
|
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_STEP, 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"))
|
|
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.OUTPUT, lambda ctx: order.append("global"))
|
|
|
|
with scoped_hooks():
|
|
register_scoped(InterceptionPoint.OUTPUT, lambda ctx: order.append("scoped"))
|
|
dispatch(InterceptionPoint.OUTPUT, _Ctx())
|
|
|
|
# outside the scope the scoped hook is gone
|
|
dispatch(InterceptionPoint.OUTPUT, _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.INPUT, _Ctx())
|
|
|
|
assert events == []
|
|
|
|
def test_event_reports_outcome(self):
|
|
events: list[HookDispatchedEvent] = []
|
|
|
|
register(InterceptionPoint.INPUT, lambda ctx: "changed")
|
|
|
|
with crewai_event_bus.scoped_handlers():
|
|
|
|
@crewai_event_bus.on(HookDispatchedEvent)
|
|
def _capture(_source, event):
|
|
events.append(event)
|
|
|
|
dispatch(InterceptionPoint.INPUT, _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].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.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())
|
|
crewai_event_bus.flush()
|
|
|
|
assert len(events) == 1
|
|
assert events[0].interception_point == "input"
|
|
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.INPUT, 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.INPUT, ctx)
|
|
noop = time.perf_counter() - start
|
|
|
|
assert noop < baseline * 50 + 5e-3
|