fix: after_llm_call hooks no longer break native tool execution (#6531)

* fix: don't clobber native tool-call responses in after-LLM hooks

Registering any `after_llm_call` hook broke native tool execution: the
executor invokes `_setup_after_llm_call_hooks` on the intermediate
response that carries the model's tool calls, the non-str payload was
stringified for the response-rewrite pass, and the executor then treated
that string as a final answer instead of executing the tools. Structured
payloads (neither `str` nor `BaseModel`) now pass through untouched,
mirroring the isinstance guard `_invoke_after_llm_call_hooks` already
applies on the direct-call path; hooks still fire on the follow-up
textual response.

Fixes #6529

* style: shorten the tool-call guard comment
This commit is contained in:
Lucas Gomide
2026-07-14 10:49:21 -03:00
committed by GitHub
parent 5f4ac9f407
commit 6452608724
2 changed files with 40 additions and 0 deletions

View File

@@ -1751,6 +1751,12 @@ def _setup_after_llm_call_hooks(
if executor_context and executor_context.after_llm_call_hooks:
from crewai.hooks.llm_hooks import LLMCallHookContext
# Don't stringify structured tool-call payloads: the executor would
# treat the result as a final answer and skip tool execution (#6529).
# Hooks still run on the follow-up textual response.
if not isinstance(answer, (str, BaseModel)):
return answer
original_messages = executor_context.messages
if isinstance(answer, BaseModel):

View File

@@ -272,6 +272,40 @@ class TestLLMHooksIntegration:
assert result == "Original [hook1] [hook2]"
def test_after_hooks_do_not_clobber_native_tool_call_responses(
self, mock_executor
):
"""A registered after hook must not break native tool execution.
Regression for crewAIInc/crewAI#6529: `_setup_after_llm_call_hooks`
stringified structured tool-call payloads, so the executor treated the
raw tool call as the final answer and never executed the tool. Non-str,
non-BaseModel responses now pass through untouched; hooks still fire on
textual responses.
"""
from crewai.utilities.agent_utils import _setup_after_llm_call_hooks
observed = []
def observer(context):
observed.append(context.response)
return None
register_after_llm_call_hook(observer)
mock_executor.after_llm_call_hooks = get_after_llm_call_hooks()
tool_calls = [Mock()] # structured native tool-call payload
result = _setup_after_llm_call_hooks(
mock_executor, tool_calls, printer=Mock(), verbose=False
)
assert result is tool_calls
text = _setup_after_llm_call_hooks(
mock_executor, "final answer", printer=Mock(), verbose=False
)
assert text == "final answer"
assert observed == ["final answer"]
def test_unregister_before_hook(self):
"""Test that before hooks can be unregistered."""
def test_hook(context):