Files
crewAI/lib/crewai/tests/hooks/test_interception_conformance.py
Lucas Gomide 04178f8f58 fix: dispatch tool_selection on the standalone agent kickoff path
`agent.kickoff()` builds its executor in `_prepare_kickoff` without going
through `create_agent_executor`, so `tool_selection` hooks never saw the
tools it assembled (apps, MCP, memory tools included) and the point could
not be trusted as a policy gate. Dispatch the same seam there after the
tool list is final, and add conformance tests for both paths.
2026-07-14 03:38:54 -03:00

177 lines
4.8 KiB
Python

"""Conformance suite for the framework-native interception points.
For each wired point this suite asserts the shared contract: the probe hook
sees a well-shaped payload, an in-place/returned modification is honored, and a
:class:`HookAborted` interrupts the step.
"""
from __future__ import annotations
from crewai.flow.flow import Flow, listen, start
from crewai.hooks.dispatch import (
HookAborted,
InterceptionPoint,
clear_all,
on,
)
import pytest
@pytest.fixture(autouse=True)
def clear_dispatch_registry():
clear_all()
yield
clear_all()
class _SimpleFlow(Flow):
@start()
def begin(self):
return "begin"
@listen(begin)
def finish(self, _):
return "flow-result"
class TestFlowExecutionBoundaries:
"""execution_start / input / output / execution_end on a flow."""
def test_all_boundary_points_fire_once(self):
fired: list[str] = []
for point in (
InterceptionPoint.EXECUTION_START,
InterceptionPoint.INPUT,
InterceptionPoint.OUTPUT,
InterceptionPoint.EXECUTION_END,
):
@on(point)
def _probe(ctx, _point=point):
fired.append(_point.value)
_SimpleFlow().kickoff(inputs={"seed": 1})
assert fired == [
"execution_start",
"input",
"output",
"execution_end",
]
def test_output_modification_is_honored(self):
@on(InterceptionPoint.OUTPUT)
def rewrite(ctx):
return "intercepted"
result = _SimpleFlow().kickoff()
assert result == "intercepted"
def test_input_payload_carries_inputs(self):
seen: dict = {}
@on(InterceptionPoint.INPUT)
def capture(ctx):
seen.update(ctx.payload or {})
_SimpleFlow().kickoff(inputs={"seed": 42})
assert seen == {"seed": 42}
def test_abort_at_execution_start_interrupts(self):
@on(InterceptionPoint.EXECUTION_START)
def block(ctx):
raise HookAborted(reason="not allowed", source="policy")
with pytest.raises(HookAborted) as exc:
_SimpleFlow().kickoff()
assert exc.value.reason == "not allowed"
class TestFlowStepPoints:
"""pre_step / post_step for flow methods (kind=flow_method)."""
def test_pre_and_post_step_fire_per_method(self):
kinds: list[tuple[str, str | None]] = []
@on(InterceptionPoint.PRE_STEP)
def pre(ctx):
kinds.append(("pre", ctx.step_name))
@on(InterceptionPoint.POST_STEP)
def post(ctx):
kinds.append(("post", ctx.step_name))
_SimpleFlow().kickoff()
assert ("pre", "begin") in kinds
assert ("post", "begin") in kinds
assert ("pre", "finish") in kinds
assert ("post", "finish") in kinds
def test_post_step_can_rewrite_method_output(self):
@on(InterceptionPoint.POST_STEP)
def rewrite(ctx):
if ctx.step_name == "finish":
return "rewritten"
return None
assert _SimpleFlow().kickoff() == "rewritten"
class TestToolSelection:
"""tool_selection must gate every path that hands tools to an executor."""
@staticmethod
def _make_agent():
from crewai import Agent
return Agent(
role="Selector",
goal="g",
backstory="b",
llm="gpt-4o-mini",
)
@staticmethod
def _make_tools():
from crewai.tools import tool
@tool("allowed_tool")
def allowed_tool() -> str:
"""A tool the policy permits."""
return "ok"
@tool("blocked_tool")
def blocked_tool() -> str:
"""A tool the policy strips."""
return "nope"
return [allowed_tool, blocked_tool]
def test_hook_filters_tools_on_executor_creation(self):
@on(InterceptionPoint.TOOL_SELECTION)
def drop_blocked(ctx):
return [t for t in ctx.payload if t.name != "blocked_tool"]
agent = self._make_agent()
agent.create_agent_executor(tools=self._make_tools())
names = [t.name for t in agent.agent_executor.original_tools]
assert "allowed_tool" in names
assert "blocked_tool" not in names
def test_hook_filters_tools_on_standalone_kickoff_prepare(self):
@on(InterceptionPoint.TOOL_SELECTION)
def drop_blocked(ctx):
return [t for t in ctx.payload if t.name != "blocked_tool"]
agent = self._make_agent()
agent.tools = self._make_tools()
_executor, _inputs, agent_info, parsed_tools = agent._prepare_kickoff("hi")
names = [t.name for t in agent_info["tools"]]
assert "allowed_tool" in names
assert "blocked_tool" not in names
assert all(t.name != "blocked_tool" for t in parsed_tools)