From 690d00198cf13fa4557d635a5ad885bfea90ccc9 Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Sun, 1 Mar 2026 03:03:31 -0800 Subject: [PATCH] refactor: streamline LiteAgent tool handling and enhance after-LLM call hooks - Simplified the conversion of tools to OpenAI schema by removing redundant mapping. - Updated the after-LLM call hooks to support list-type answers, ensuring native tool calls are returned unchanged. - Added tests to verify correct usage count for native tools and ensure proper handling of duplicate tool names. - Enhanced existing tests to confirm functionality with after-LLM hooks active, addressing previous issues with tool call processing. --- lib/crewai/src/crewai/lite_agent.py | 11 +- .../src/crewai/utilities/agent_utils.py | 19 ++- lib/crewai/tests/agents/test_lite_agent.py | 129 ++++++++++++++++++ 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index 22509f50e..8093a06d3 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -914,14 +914,10 @@ class LiteAgent(FlowTrackable, BaseModel): Returns: AgentFinish: The final result of the agent execution. """ - openai_tools, available_functions, _ = convert_tools_to_openai_schema( - self.tools + openai_tools, available_functions, original_tools_by_name = ( + convert_tools_to_openai_schema(self.tools) ) - original_tools_by_name: dict[str, BaseTool] = { - sanitize_tool_name(t.name): t for t in self.tools - } - while True: try: if has_reached_max_iterations(self._iterations, self.max_iterations): @@ -1348,9 +1344,6 @@ class LiteAgent(FlowTrackable, BaseModel): color="red", ) - if original_tool: - original_tool.current_usage_count += 1 - if not error_event_emitted: crewai_event_bus.emit( self, diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index e4f3d3fee..2077b595c 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -1245,26 +1245,34 @@ def _setup_before_llm_call_hooks( def _setup_after_llm_call_hooks( executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None, - answer: str | BaseModel, + answer: str | BaseModel | list[Any], printer: Printer, verbose: bool = True, -) -> str | BaseModel: +) -> str | BaseModel | list[Any]: """Setup and invoke after_llm_call hooks for the executor context. Args: executor_context: The executor context to setup the hooks for. - answer: The LLM response (string or Pydantic model). + answer: The LLM response (string, Pydantic model, or list of native + tool calls). printer: Printer instance for error logging. verbose: Whether to print output. Returns: - The potentially modified response (string or Pydantic model). + The potentially modified response. List-type answers (native tool + calls) are always returned unchanged so that callers can rely on + ``isinstance(answer, list)`` checks. """ if executor_context and executor_context.after_llm_call_hooks: from crewai.hooks.llm_hooks import LLMCallHookContext original_messages = executor_context.messages + # Native tool-call lists must survive hooks unchanged. We provide a + # stringified representation to hook context for observability but + # always return the original list so callers can detect tool calls. + is_tool_call_list = isinstance(answer, list) + # For Pydantic models, serialize to JSON for hooks if isinstance(answer, BaseModel): pydantic_answer = answer @@ -1303,6 +1311,9 @@ def _setup_after_llm_call_hooks( else: executor_context.messages = [] + if is_tool_call_list: + return answer + # If hooks modified the response, update answer accordingly if pydantic_answer is not None: # For Pydantic models, reparse the JSON if it was modified diff --git a/lib/crewai/tests/agents/test_lite_agent.py b/lib/crewai/tests/agents/test_lite_agent.py index ae355b338..b795b0653 100644 --- a/lib/crewai/tests/agents/test_lite_agent.py +++ b/lib/crewai/tests/agents/test_lite_agent.py @@ -1343,3 +1343,132 @@ def test_lite_agent_native_parallel_tool_calls(): ] assert len(assistant_tc_messages) == 1 assert len(assistant_tc_messages[0]["tool_calls"]) == 2 + + +def test_lite_agent_native_tool_usage_count_no_double_increment(): + """current_usage_count must increment exactly once per native tool call. + + BaseTool.run() already increments the counter internally, so the native + tool call handler must not add a second increment. + """ + tool_call = [_make_openai_tool_call("call_1", "calculate", '{"expression": "1+1"}')] + + llm = _NativeToolCallLLM(tool_calls=[tool_call], final_answer="2") + calc_tool = CalculatorTool() + assert calc_tool.current_usage_count == 0 + + agent = LiteAgent( + role="Calculator", goal="Compute", backstory="Math agent", + llm=llm, tools=[calc_tool], + ) + agent.kickoff("What is 1+1?") + + assert calc_tool.current_usage_count == 1 + + +def test_lite_agent_native_tool_max_usage_count_respected(): + """A tool with max_usage_count=1 should be usable exactly once, not blocked after 1 call.""" + call_round_1 = [_make_openai_tool_call("c1", "calculate", '{"expression": "1+1"}')] + call_round_2 = [_make_openai_tool_call("c2", "calculate", '{"expression": "2+2"}')] + + llm = _NativeToolCallLLM( + tool_calls=[call_round_1, call_round_2], final_answer="done" + ) + calc_tool = CalculatorTool() + calc_tool.max_usage_count = 2 + + agent = LiteAgent( + role="Calculator", goal="Compute", backstory="Math agent", + llm=llm, tools=[calc_tool], + ) + agent.kickoff("Compute 1+1 then 2+2") + + executed = [r for r in agent.tools_results if "usage limit" not in r["result"]] + assert len(executed) == 2 + assert calc_tool.current_usage_count == 2 + + +def test_lite_agent_native_tool_calls_with_after_llm_hook(): + """Native tool calls must be processed even when after_llm_call hooks are active. + + Regression test: _setup_after_llm_call_hooks was converting the list of + tool calls to a string via str(), causing isinstance(answer, list) to fail + in _invoke_loop_native_tools and silently returning the stringified list as + the agent's final answer. + """ + hook_called = {"count": 0} + + def after_hook(context): + hook_called["count"] += 1 + return None + + tool_call = [_make_openai_tool_call("call_1", "calculate", '{"expression": "6*7"}')] + + llm = _NativeToolCallLLM(tool_calls=[tool_call], final_answer="The answer is 42") + agent = LiteAgent( + role="Calculator", goal="Compute", backstory="Math agent", + llm=llm, tools=[CalculatorTool()], + ) + agent._after_llm_call_hooks.append(after_hook) + + result = agent.kickoff("What is 6 * 7?") + + assert hook_called["count"] >= 1 + assert len(agent.tools_results) == 1 + assert agent.tools_results[0]["tool_name"] == "calculate" + assert "42" in result.raw + + +def test_lite_agent_native_parallel_tool_calls_with_after_llm_hook(): + """Multiple native tool calls in a single response must work with hooks active.""" + hook_called = {"count": 0} + + def after_hook(context): + hook_called["count"] += 1 + return None + + tool_calls = [ + _make_openai_tool_call("call_1", "calculate", '{"expression": "2+3"}'), + _make_openai_tool_call("call_2", "calculate", '{"expression": "4+5"}'), + ] + + llm = _NativeToolCallLLM(tool_calls=[tool_calls], final_answer="5 and 9") + agent = LiteAgent( + role="Calculator", goal="Compute", backstory="Math agent", + llm=llm, tools=[CalculatorTool()], + ) + agent._after_llm_call_hooks.append(after_hook) + + result = agent.kickoff("What is 2+3 and 4+5?") + + assert hook_called["count"] >= 1 + assert len(agent.tools_results) == 2 + tool_names = [r["tool_name"] for r in agent.tools_results] + assert tool_names == ["calculate", "calculate"] + + +def test_lite_agent_native_duplicate_tool_names_resolved(): + """Two tools with the same sanitized name should both be usable via dedup suffixes. + + convert_tools_to_openai_schema renames duplicates (e.g. calculate -> calculate_2). + The original_tools_by_name mapping must honour these deduplicated names so + result_as_answer, max_usage_count, and usage tracking work for every tool. + """ + tool_a = CalculatorTool() + tool_a.result_as_answer = True + + tool_b = CalculatorTool() + + tool_call = [ + _make_openai_tool_call("c1", "calculate_2", '{"expression": "9+1"}'), + ] + llm = _NativeToolCallLLM(tool_calls=[tool_call], final_answer="fallback") + agent = LiteAgent( + role="Calculator", goal="Compute", backstory="Math agent", + llm=llm, tools=[tool_a, tool_b], + ) + result = agent.kickoff("What is 9+1?") + + assert len(agent.tools_results) == 1 + assert agent.tools_results[0]["tool_name"] == "calculate_2" + assert "10" in agent.tools_results[0]["result"]