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.
This commit is contained in:
Lucas Gomide
2026-07-14 03:38:54 -03:00
parent 356215a9f7
commit 04178f8f58
2 changed files with 73 additions and 0 deletions

View File

@@ -1433,6 +1433,22 @@ class Agent(BaseAgent):
if sanitize_tool_name(mt.name) not in existing_names
)
from crewai.hooks.contexts import ToolSelectionContext
from crewai.hooks.dispatch import InterceptionPoint, dispatch
# Same policy seam as create_agent_executor: standalone kickoff must
# also let tool_selection hooks see (and filter) the final tool list,
# or the point can't be trusted to gate what an agent can use.
selection_ctx = ToolSelectionContext(
agent=self,
agent_role=getattr(self, "role", None),
crew=self.crew,
tools=raw_tools,
payload=raw_tools,
)
dispatch(InterceptionPoint.TOOL_SELECTION, selection_ctx)
raw_tools = selection_ctx.payload
parsed_tools = parse_tools(raw_tools)
agent_info = {

View File

@@ -117,3 +117,60 @@ class TestFlowStepPoints:
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)