mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
`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.
177 lines
4.8 KiB
Python
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)
|