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.
This commit is contained in:
Joao Moura
2026-03-01 03:03:31 -08:00
parent 28d460c651
commit 690d00198c
3 changed files with 146 additions and 13 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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"]