diff --git a/lib/crewai/src/crewai/hooks/llm_hooks.py b/lib/crewai/src/crewai/hooks/llm_hooks.py index 3a10243e2..2388396c9 100644 --- a/lib/crewai/src/crewai/hooks/llm_hooks.py +++ b/lib/crewai/src/crewai/hooks/llm_hooks.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from crewai.events.event_listener import event_listener from crewai.hooks.types import AfterLLMCallHookType, BeforeLLMCallHookType @@ -9,17 +9,22 @@ from crewai.utilities.printer import Printer if TYPE_CHECKING: from crewai.agents.crew_agent_executor import CrewAgentExecutor + from crewai.lite_agent import LiteAgent + from crewai.llms.base_llm import BaseLLM + from crewai.utilities.types import LLMMessage class LLMCallHookContext: - """Context object passed to LLM call hooks with full executor access. + """Context object passed to LLM call hooks. - Provides hooks with complete access to the executor state, allowing + Provides hooks with complete access to the execution state, allowing modification of messages, responses, and executor attributes. + Supports both executor-based calls (agents in crews/flows) and direct LLM calls. + Attributes: - executor: Full reference to the CrewAgentExecutor instance - messages: Direct reference to executor.messages (mutable list). + executor: Reference to the executor (CrewAgentExecutor/LiteAgent) or None for direct calls + messages: Direct reference to messages (mutable list). Can be modified in both before_llm_call and after_llm_call hooks. Modifications in after_llm_call hooks persist to the next iteration, allowing hooks to modify conversation history for subsequent LLM calls. @@ -27,33 +32,75 @@ class LLMCallHookContext: Do NOT replace the list (e.g., context.messages = []), as this will break the executor. Use context.messages.append() or context.messages.extend() instead of assignment. - agent: Reference to the agent executing the task - task: Reference to the task being executed - crew: Reference to the crew instance + agent: Reference to the agent executing the task (None for direct LLM calls) + task: Reference to the task being executed (None for direct LLM calls or LiteAgent) + crew: Reference to the crew instance (None for direct LLM calls or LiteAgent) llm: Reference to the LLM instance - iterations: Current iteration count + iterations: Current iteration count (0 for direct LLM calls) response: LLM response string (only set for after_llm_call hooks). Can be modified by returning a new string from after_llm_call hook. """ + executor: CrewAgentExecutor | LiteAgent | None + messages: list[LLMMessage] + agent: Any + task: Any + crew: Any + llm: BaseLLM | None | str | Any + iterations: int + response: str | None + def __init__( self, - executor: CrewAgentExecutor, + executor: CrewAgentExecutor | LiteAgent | None = None, response: str | None = None, + messages: list[LLMMessage] | None = None, + llm: BaseLLM | str | Any | None = None, # TODO: look into + agent: Any | None = None, + task: Any | None = None, + crew: Any | None = None, ) -> None: - """Initialize hook context with executor reference. + """Initialize hook context with executor reference or direct parameters. Args: - executor: The CrewAgentExecutor instance + executor: The CrewAgentExecutor or LiteAgent instance (None for direct LLM calls) response: Optional response string (for after_llm_call hooks) + messages: Optional messages list (for direct LLM calls when executor is None) + llm: Optional LLM instance (for direct LLM calls when executor is None) + agent: Optional agent reference (for direct LLM calls when executor is None) + task: Optional task reference (for direct LLM calls when executor is None) + crew: Optional crew reference (for direct LLM calls when executor is None) """ - self.executor = executor - self.messages = executor.messages - self.agent = executor.agent - self.task = executor.task - self.crew = executor.crew - self.llm = executor.llm - self.iterations = executor.iterations + if executor is not None: + # Existing path: extract from executor + self.executor = executor + self.messages = executor.messages + self.llm = executor.llm + self.iterations = executor.iterations + # Handle CrewAgentExecutor vs LiteAgent differences + if hasattr(executor, "agent"): + self.agent = executor.agent + self.task = cast("CrewAgentExecutor", executor).task + self.crew = cast("CrewAgentExecutor", executor).crew + else: + # LiteAgent case - is the agent itself, doesn't have task/crew + self.agent = ( + executor.original_agent + if hasattr(executor, "original_agent") + else executor + ) + self.task = None + self.crew = None + else: + # New path: direct LLM call with explicit parameters + self.executor = None + self.messages = messages or [] + self.llm = llm + self.agent = agent + self.task = task + self.crew = crew + self.iterations = 0 + self.response = response def request_human_input( diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index 5c7fcd822..9bb3193e5 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -38,6 +38,8 @@ from crewai.events.types.agent_events import ( ) from crewai.events.types.logging_events import AgentLogsExecutionEvent from crewai.flow.flow_trackable import FlowTrackable +from crewai.hooks.llm_hooks import get_after_llm_call_hooks, get_before_llm_call_hooks +from crewai.hooks.types import AfterLLMCallHookType, BeforeLLMCallHookType from crewai.lite_agent_output import LiteAgentOutput from crewai.llm import LLM from crewai.llms.base_llm import BaseLLM @@ -155,6 +157,12 @@ class LiteAgent(FlowTrackable, BaseModel): _guardrail: GuardrailCallable | None = PrivateAttr(default=None) _guardrail_retry_count: int = PrivateAttr(default=0) _callbacks: list[TokenCalcHandler] = PrivateAttr(default_factory=list) + _before_llm_call_hooks: list[BeforeLLMCallHookType] = PrivateAttr( + default_factory=get_before_llm_call_hooks + ) + _after_llm_call_hooks: list[AfterLLMCallHookType] = PrivateAttr( + default_factory=get_after_llm_call_hooks + ) @model_validator(mode="after") def setup_llm(self) -> Self: @@ -246,6 +254,26 @@ class LiteAgent(FlowTrackable, BaseModel): """Return the original role for compatibility with tool interfaces.""" return self.role + @property + def before_llm_call_hooks(self) -> list[BeforeLLMCallHookType]: + """Get the before_llm_call hooks for this agent.""" + return self._before_llm_call_hooks + + @property + def after_llm_call_hooks(self) -> list[AfterLLMCallHookType]: + """Get the after_llm_call hooks for this agent.""" + return self._after_llm_call_hooks + + @property + def messages(self) -> list[LLMMessage]: + """Get the messages list for hook context compatibility.""" + return self._messages + + @property + def iterations(self) -> int: + """Get the current iteration count for hook context compatibility.""" + return self._iterations + def kickoff( self, messages: str | list[LLMMessage], @@ -504,7 +532,7 @@ class LiteAgent(FlowTrackable, BaseModel): AgentFinish: The final result of the agent execution. """ # Execute the agent loop - formatted_answer = None + formatted_answer: AgentAction | AgentFinish | None = None while not isinstance(formatted_answer, AgentFinish): try: if has_reached_max_iterations(self._iterations, self.max_iterations): @@ -526,6 +554,7 @@ class LiteAgent(FlowTrackable, BaseModel): callbacks=self._callbacks, printer=self._printer, from_agent=self, + executor_context=self, ) except Exception as e: diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py index 554e8e7ea..8eb052683 100644 --- a/lib/crewai/src/crewai/llm.py +++ b/lib/crewai/src/crewai/llm.py @@ -1642,6 +1642,10 @@ class LLM(BaseLLM): if message.get("role") == "system": msg_role: Literal["assistant"] = "assistant" message["role"] = msg_role + + if not self._invoke_before_llm_call_hooks(messages, from_agent): + raise ValueError("LLM call blocked by before_llm_call hook") + # --- 5) Set up callbacks if provided with suppress_warnings(): if callbacks and len(callbacks) > 0: @@ -1651,7 +1655,16 @@ class LLM(BaseLLM): params = self._prepare_completion_params(messages, tools) # --- 7) Make the completion call and handle response if self.stream: - return self._handle_streaming_response( + result = self._handle_streaming_response( + params=params, + callbacks=callbacks, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, + response_model=response_model, + ) + else: + result = self._handle_non_streaming_response( params=params, callbacks=callbacks, available_functions=available_functions, @@ -1660,14 +1673,12 @@ class LLM(BaseLLM): response_model=response_model, ) - return self._handle_non_streaming_response( - params=params, - callbacks=callbacks, - available_functions=available_functions, - from_task=from_task, - from_agent=from_agent, - response_model=response_model, - ) + if isinstance(result, str): + result = self._invoke_after_llm_call_hooks( + messages, result, from_agent + ) + + return result except LLMContextLengthExceededError: # Re-raise LLMContextLengthExceededError as it should be handled # by the CrewAgentExecutor._invoke_loop method, which can then decide diff --git a/lib/crewai/src/crewai/llms/base_llm.py b/lib/crewai/src/crewai/llms/base_llm.py index fa4e7be97..bb833ccc8 100644 --- a/lib/crewai/src/crewai/llms/base_llm.py +++ b/lib/crewai/src/crewai/llms/base_llm.py @@ -314,7 +314,7 @@ class BaseLLM(ABC): call_type: LLMCallType, from_task: Task | None = None, from_agent: Agent | None = None, - messages: str | list[dict[str, Any]] | None = None, + messages: str | list[LLMMessage] | None = None, ) -> None: """Emit LLM call completed event.""" crewai_event_bus.emit( @@ -586,3 +586,134 @@ class BaseLLM(ABC): Dictionary with token usage totals """ return UsageMetrics(**self._token_usage) + + def _invoke_before_llm_call_hooks( + self, + messages: list[LLMMessage], + from_agent: Agent | None = None, + ) -> bool: + """Invoke before_llm_call hooks for direct LLM calls (no agent context). + + This method should be called by native provider implementations before + making the actual LLM call when from_agent is None (direct calls). + + Args: + messages: The messages being sent to the LLM + from_agent: The agent making the call (None for direct calls) + + Returns: + True if LLM call should proceed, False if blocked by hook + + Example: + >>> # In a native provider's call() method: + >>> if from_agent is None and not self._invoke_before_llm_call_hooks( + ... messages, from_agent + ... ): + ... raise ValueError("LLM call blocked by hook") + """ + # Only invoke hooks for direct calls (no agent context) + if from_agent is not None: + return True + + from crewai.hooks.llm_hooks import ( + LLMCallHookContext, + get_before_llm_call_hooks, + ) + from crewai.utilities.printer import Printer + + before_hooks = get_before_llm_call_hooks() + if not before_hooks: + return True + + hook_context = LLMCallHookContext( + executor=None, + messages=messages, + llm=self, + agent=None, + task=None, + crew=None, + ) + printer = Printer() + + try: + for hook in before_hooks: + result = hook(hook_context) + if result is False: + printer.print( + content="LLM call blocked by before_llm_call hook", + color="yellow", + ) + return False + except Exception as e: + printer.print( + content=f"Error in before_llm_call hook: {e}", + color="yellow", + ) + + return True + + def _invoke_after_llm_call_hooks( + self, + messages: list[LLMMessage], + response: str, + from_agent: Agent | None = None, + ) -> str: + """Invoke after_llm_call hooks for direct LLM calls (no agent context). + + This method should be called by native provider implementations after + receiving the LLM response when from_agent is None (direct calls). + + Args: + messages: The messages that were sent to the LLM + response: The response from the LLM + from_agent: The agent that made the call (None for direct calls) + + Returns: + The potentially modified response string + + Example: + >>> # In a native provider's call() method: + >>> if from_agent is None and isinstance(result, str): + ... result = self._invoke_after_llm_call_hooks( + ... messages, result, from_agent + ... ) + """ + # Only invoke hooks for direct calls (no agent context) + if from_agent is not None or not isinstance(response, str): + return response + + from crewai.hooks.llm_hooks import ( + LLMCallHookContext, + get_after_llm_call_hooks, + ) + from crewai.utilities.printer import Printer + + after_hooks = get_after_llm_call_hooks() + if not after_hooks: + return response + + hook_context = LLMCallHookContext( + executor=None, + messages=messages, + llm=self, + agent=None, + task=None, + crew=None, + response=response, + ) + printer = Printer() + modified_response = response + + try: + for hook in after_hooks: + result = hook(hook_context) + if result is not None and isinstance(result, str): + modified_response = result + hook_context.response = modified_response + except Exception as e: + printer.print( + content=f"Error in after_llm_call hook: {e}", + color="yellow", + ) + + return modified_response diff --git a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py index b6cc1a2e6..fd637adbc 100644 --- a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py +++ b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py @@ -187,6 +187,9 @@ class AnthropicCompletion(BaseLLM): messages ) + if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent): + raise ValueError("LLM call blocked by before_llm_call hook") + # Prepare completion parameters completion_params = self._prepare_completion_params( formatted_messages, system_message, tools @@ -494,7 +497,9 @@ class AnthropicCompletion(BaseLLM): if usage.get("total_tokens", 0) > 0: logging.info(f"Anthropic API usage: {usage}") - return content + return self._invoke_after_llm_call_hooks( + params["messages"], content, from_agent + ) def _handle_streaming_completion( self, @@ -588,7 +593,9 @@ class AnthropicCompletion(BaseLLM): messages=params["messages"], ) - return full_response + return self._invoke_after_llm_call_hooks( + params["messages"], full_response, from_agent + ) def _handle_tool_use_conversation( self, diff --git a/lib/crewai/src/crewai/llms/providers/azure/completion.py b/lib/crewai/src/crewai/llms/providers/azure/completion.py index 7f3db08a8..fe4416b1d 100644 --- a/lib/crewai/src/crewai/llms/providers/azure/completion.py +++ b/lib/crewai/src/crewai/llms/providers/azure/completion.py @@ -216,6 +216,9 @@ class AzureCompletion(BaseLLM): # Format messages for Azure formatted_messages = self._format_messages_for_azure(messages) + if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent): + raise ValueError("LLM call blocked by before_llm_call hook") + # Prepare completion parameters completion_params = self._prepare_completion_params( formatted_messages, tools, response_model @@ -550,6 +553,10 @@ class AzureCompletion(BaseLLM): messages=params["messages"], ) + content = self._invoke_after_llm_call_hooks( + params["messages"], content, from_agent + ) + except Exception as e: if is_context_length_exceeded(e): logging.error(f"Context window exceeded: {e}") @@ -642,7 +649,9 @@ class AzureCompletion(BaseLLM): messages=params["messages"], ) - return full_response + return self._invoke_after_llm_call_hooks( + params["messages"], full_response, from_agent + ) async def _ahandle_completion( self, diff --git a/lib/crewai/src/crewai/llms/providers/bedrock/completion.py b/lib/crewai/src/crewai/llms/providers/bedrock/completion.py index cfae92ccb..2057bd871 100644 --- a/lib/crewai/src/crewai/llms/providers/bedrock/completion.py +++ b/lib/crewai/src/crewai/llms/providers/bedrock/completion.py @@ -312,9 +312,14 @@ class BedrockCompletion(BaseLLM): # Format messages for Converse API formatted_messages, system_message = self._format_messages_for_converse( - messages # type: ignore[arg-type] + messages ) + if not self._invoke_before_llm_call_hooks( + cast(list[LLMMessage], formatted_messages), from_agent + ): + raise ValueError("LLM call blocked by before_llm_call hook") + # Prepare request body body: BedrockConverseRequestBody = { "inferenceConfig": self._get_inference_config(), @@ -356,11 +361,19 @@ class BedrockCompletion(BaseLLM): if self.stream: return self._handle_streaming_converse( - formatted_messages, body, available_functions, from_task, from_agent + cast(list[LLMMessage], formatted_messages), + body, + available_functions, + from_task, + from_agent, ) return self._handle_converse( - formatted_messages, body, available_functions, from_task, from_agent + cast(list[LLMMessage], formatted_messages), + body, + available_functions, + from_task, + from_agent, ) except Exception as e: @@ -481,7 +494,7 @@ class BedrockCompletion(BaseLLM): def _handle_converse( self, - messages: list[dict[str, Any]], + messages: list[LLMMessage], body: BedrockConverseRequestBody, available_functions: Mapping[str, Any] | None = None, from_task: Any | None = None, @@ -605,7 +618,11 @@ class BedrockCompletion(BaseLLM): messages=messages, ) - return text_content + return self._invoke_after_llm_call_hooks( + messages, + text_content, + from_agent, + ) except ClientError as e: # Handle all AWS ClientError exceptions as per documentation @@ -662,7 +679,7 @@ class BedrockCompletion(BaseLLM): def _handle_streaming_converse( self, - messages: list[dict[str, Any]], + messages: list[LLMMessage], body: BedrockConverseRequestBody, available_functions: dict[str, Any] | None = None, from_task: Any | None = None, @@ -1149,16 +1166,25 @@ class BedrockCompletion(BaseLLM): messages=messages, ) - return full_response + return self._invoke_after_llm_call_hooks( + messages, + full_response, + from_agent, + ) def _format_messages_for_converse( - self, messages: str | list[dict[str, str]] + self, messages: str | list[LLMMessage] ) -> tuple[list[dict[str, Any]], str | None]: - """Format messages for Converse API following AWS documentation.""" - # Use base class formatting first - formatted_messages = self._format_messages(messages) # type: ignore[arg-type] + """Format messages for Converse API following AWS documentation. - converse_messages = [] + Note: Returns dict[str, Any] instead of LLMMessage because Bedrock uses + a different content structure: {"role": str, "content": [{"text": str}]} + rather than the standard {"role": str, "content": str}. + """ + # Use base class formatting first + formatted_messages = self._format_messages(messages) + + converse_messages: list[dict[str, Any]] = [] system_message: str | None = None for message in formatted_messages: diff --git a/lib/crewai/src/crewai/llms/providers/gemini/completion.py b/lib/crewai/src/crewai/llms/providers/gemini/completion.py index 1b89d0667..0917bf555 100644 --- a/lib/crewai/src/crewai/llms/providers/gemini/completion.py +++ b/lib/crewai/src/crewai/llms/providers/gemini/completion.py @@ -246,6 +246,11 @@ class GeminiCompletion(BaseLLM): messages ) + messages_for_hooks = self._convert_contents_to_dict(formatted_content) + + if not self._invoke_before_llm_call_hooks(messages_for_hooks, from_agent): + raise ValueError("LLM call blocked by before_llm_call hook") + config = self._prepare_generation_config( system_instruction, tools, response_model ) @@ -559,7 +564,9 @@ class GeminiCompletion(BaseLLM): messages=messages_for_event, ) - return content + return self._invoke_after_llm_call_hooks( + messages_for_event, content, from_agent + ) def _handle_streaming_completion( self, @@ -639,7 +646,9 @@ class GeminiCompletion(BaseLLM): messages=messages_for_event, ) - return full_response + return self._invoke_after_llm_call_hooks( + messages_for_event, full_response, from_agent + ) async def _ahandle_completion( self, @@ -787,7 +796,159 @@ class GeminiCompletion(BaseLLM): messages=messages_for_event, ) - return full_response + return self._invoke_after_llm_call_hooks( + messages_for_event, full_response, from_agent + ) + + async def _ahandle_completion( + self, + contents: list[types.Content], + system_instruction: str | None, + config: types.GenerateContentConfig, + available_functions: dict[str, Any] | None = None, + from_task: Any | None = None, + from_agent: Any | None = None, + response_model: type[BaseModel] | None = None, + ) -> str | Any: + """Handle async non-streaming content generation.""" + try: + # The API accepts list[Content] but mypy is overly strict about variance + contents_for_api: Any = contents + response = await self.client.aio.models.generate_content( + model=self.model, + contents=contents_for_api, + config=config, + ) + + usage = self._extract_token_usage(response) + except Exception as e: + if is_context_length_exceeded(e): + logging.error(f"Context window exceeded: {e}") + raise LLMContextLengthExceededError(str(e)) from e + raise e from e + + self._track_token_usage_internal(usage) + + if response.candidates and (self.tools or available_functions): + candidate = response.candidates[0] + if candidate.content and candidate.content.parts: + for part in candidate.content.parts: + if hasattr(part, "function_call") and part.function_call: + function_name = part.function_call.name + if function_name is None: + continue + function_args = ( + dict(part.function_call.args) + if part.function_call.args + else {} + ) + + result = self._handle_tool_execution( + function_name=function_name, + function_args=function_args, + available_functions=available_functions or {}, + from_task=from_task, + from_agent=from_agent, + ) + + if result is not None: + return result + + content = response.text or "" + content = self._apply_stop_words(content) + + messages_for_event = self._convert_contents_to_dict(contents) + + self._emit_call_completed_event( + response=content, + call_type=LLMCallType.LLM_CALL, + from_task=from_task, + from_agent=from_agent, + messages=messages_for_event, + ) + + return content + + async def _ahandle_streaming_completion( + self, + contents: list[types.Content], + config: types.GenerateContentConfig, + available_functions: dict[str, Any] | None = None, + from_task: Any | None = None, + from_agent: Any | None = None, + response_model: type[BaseModel] | None = None, + ) -> str: + """Handle async streaming content generation.""" + full_response = "" + function_calls: dict[str, dict[str, Any]] = {} + + # The API accepts list[Content] but mypy is overly strict about variance + contents_for_api: Any = contents + stream = await self.client.aio.models.generate_content_stream( + model=self.model, + contents=contents_for_api, + config=config, + ) + async for chunk in stream: + if chunk.text: + full_response += chunk.text + self._emit_stream_chunk_event( + chunk=chunk.text, + from_task=from_task, + from_agent=from_agent, + ) + + if chunk.candidates: + candidate = chunk.candidates[0] + if candidate.content and candidate.content.parts: + for part in candidate.content.parts: + if hasattr(part, "function_call") and part.function_call: + call_id = part.function_call.name or "default" + if call_id not in function_calls: + function_calls[call_id] = { + "name": part.function_call.name, + "args": dict(part.function_call.args) + if part.function_call.args + else {}, + } + + if function_calls and available_functions: + for call_data in function_calls.values(): + function_name = call_data["name"] + function_args = call_data["args"] + + # Skip if function_name is None + if not isinstance(function_name, str): + continue + + # Ensure function_args is a dict + if not isinstance(function_args, dict): + function_args = {} + + result = self._handle_tool_execution( + function_name=function_name, + function_args=function_args, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, + ) + + if result is not None: + return result + + messages_for_event = self._convert_contents_to_dict(contents) + + self._emit_call_completed_event( + response=full_response, + call_type=LLMCallType.LLM_CALL, + from_task=from_task, + from_agent=from_agent, + messages=messages_for_event, + ) + + return self._invoke_after_llm_call_hooks( + messages_for_event, full_response, from_agent + ) def supports_function_calling(self) -> bool: """Check if the model supports function calling.""" @@ -851,7 +1012,7 @@ class GeminiCompletion(BaseLLM): def _convert_contents_to_dict( self, contents: list[types.Content], - ) -> list[dict[str, str]]: + ) -> list[LLMMessage]: """Convert contents to dict format.""" result: list[dict[str, str]] = [] for content_obj in contents: diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 974b61e58..f38235dce 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -190,6 +190,9 @@ class OpenAICompletion(BaseLLM): formatted_messages = self._format_messages(messages) + if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent): + raise ValueError("LLM call blocked by before_llm_call hook") + completion_params = self._prepare_completion_params( messages=formatted_messages, tools=tools ) @@ -474,6 +477,10 @@ class OpenAICompletion(BaseLLM): if usage.get("total_tokens", 0) > 0: logging.info(f"OpenAI API usage: {usage}") + + content = self._invoke_after_llm_call_hooks( + params["messages"], content, from_agent + ) except NotFoundError as e: error_msg = f"Model {self.model} not found: {e}" logging.error(error_msg) @@ -629,7 +636,9 @@ class OpenAICompletion(BaseLLM): messages=params["messages"], ) - return full_response + return self._invoke_after_llm_call_hooks( + params["messages"], full_response, from_agent + ) async def _ahandle_completion( self, diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 3e90ef991..973ad5596 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -237,7 +237,7 @@ def get_llm_response( from_task: Task | None = None, from_agent: Agent | LiteAgent | None = None, response_model: type[BaseModel] | None = None, - executor_context: CrewAgentExecutor | None = None, + executor_context: CrewAgentExecutor | LiteAgent | None = None, ) -> str: """Call the LLM and return the response, handling any invalid responses. @@ -727,7 +727,7 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]: def _setup_before_llm_call_hooks( - executor_context: CrewAgentExecutor | None, printer: Printer + executor_context: CrewAgentExecutor | LiteAgent | None, printer: Printer ) -> bool: """Setup and invoke before_llm_call hooks for the executor context. @@ -777,7 +777,7 @@ def _setup_before_llm_call_hooks( def _setup_after_llm_call_hooks( - executor_context: CrewAgentExecutor | None, + executor_context: CrewAgentExecutor | LiteAgent | None, answer: str, printer: Printer, ) -> str: diff --git a/lib/crewai/tests/cassettes/TestLLMHooksIntegration.test_direct_llm_call_hooks_integration.yaml b/lib/crewai/tests/cassettes/TestLLMHooksIntegration.test_direct_llm_call_hooks_integration.yaml new file mode 100644 index 000000000..ab7d60301 --- /dev/null +++ b/lib/crewai/tests/cassettes/TestLLMHooksIntegration.test_direct_llm_call_hooks_integration.yaml @@ -0,0 +1,82 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Say hello"}],"model":"gpt-4o-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '74' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFJNb9QwEL3nVww+b9Am7Ee7FyT2wCIQ0CJxqarItSdZg+Ox7AmwVPvf + KyftJv1A4uLDvHnP783MbQYgjBYbEGovWbXe5tvm6/bv5ZeDu5AmlubTzr///G778fKi+O6/iVli + 0M0PVPzAeq2o9RbZkBtgFVAyJtVivVqUZbku3vRASxptojWe8wXlrXEmL+flIp+v8+Lsnr0nozCK + DVxlAAC3/Zt8Oo1/xAbms4dKizHKBsXm1AQgAtlUETJGE1k6FrMRVOQYXW99h9bSK9jRb1DSwQcY + CHCgDpi0PLydEgPWXZTJvOusnQDSOWKZwveWr++R48mkpcYHuolPqKI2zsR9FVBGcslQZPKiR48Z + wHU/jO5RPuEDtZ4rpp/Yf3c+qIlxA88xJpZ2LBdnsxe0Ko0sjY2TUQol1R71yBznLjttaAJkk8TP + vbykPaQ2rvkf+RFQCj2jrnxAbdTjvGNbwHSe/2o7Tbg3LCKGX0ZhxQZD2oLGWnZ2OBoRD5GxrWrj + Ggw+mOFyal8tV3NZr3C5PBfZMbsDAAD//wMARXm1qUcDAAA= + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Thu, 27 Nov 2025 05:51:54 GMT + Server: + - cloudflare + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/TestLLMHooksIntegration.test_lite_agent_hooks_integration_with_real_llm.yaml b/lib/crewai/tests/cassettes/TestLLMHooksIntegration.test_lite_agent_hooks_integration_with_real_llm.yaml new file mode 100644 index 000000000..34ac74dac --- /dev/null +++ b/lib/crewai/tests/cassettes/TestLLMHooksIntegration.test_lite_agent_hooks_integration_with_real_llm.yaml @@ -0,0 +1,87 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Test Assistant. You are + a helpful test assistant\nYour personal goal is: Answer questions briefly\n\nTo + give my best complete final answer to the task respond using the exact following + format:\n\nThought: I now can give a great answer\nFinal Answer: Your final + answer must be the great and the most complete as possible, it must be outcome + described.\n\nI MUST use these formats, my job depends on it!"},{"role":"user","content":"Say + ''Hello World'' and nothing else"}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '540' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFLLbtswELzrKxY8W4HlSjasW5Gibfo6FU1fgUCTK4kuxSVIKm4a+N8L + So6ltCnQiwDt7Axndvc+AWBKshKYaHkQndXpZXNVv3vz6cWv68/b/XtX0OHuw9v9l92r/uslZ4vI + oN0eRXhgXQjqrMagyIywcMgDRtVss86z7aZYPxuAjiTqSGtsSPOLLO2UUelquSrSZZ5m+YnekhLo + WQnfEgCA++EbjRqJP1kJy8VDpUPveYOsPDcBMEc6Vhj3XvnATWCLCRRkAprB+8eW+qYNJVyBoQMI + bqBRtwgcmhgAuPEHdN/NS2W4hufDXwmvUWuCa3JaznUd1r3nMZzptZ4B3BgKPA5nSHRzQo7nDJoa + 62jn/6CyWhnl28oh92SiXx/IsgE9JgA3w6z6R/GZddTZUAX6gcNz2XI16rFpRzO0OIGBAtezerZZ + PKFXSQxcaT+bNhNctCgn6rQa3ktFMyCZpf7bzVPaY3Jlmv+RnwAh0AaUlXUolXiceGpzGE/4X23n + KQ+GmUd3qwRWQaGLm5BY816Pd8X8nQ/YVbUyDTrr1Hhcta22m/Uai3y7W7HkmPwGAAD//wMABY90 + 7msDAAA= + headers: + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 26 Nov 2025 22:52:43 GMT + Server: + - cloudflare + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/hooks/TestLLMHooksIntegration.test_direct_llm_call_hooks_integration.yaml b/lib/crewai/tests/cassettes/hooks/TestLLMHooksIntegration.test_direct_llm_call_hooks_integration.yaml new file mode 100644 index 000000000..657742652 --- /dev/null +++ b/lib/crewai/tests/cassettes/hooks/TestLLMHooksIntegration.test_direct_llm_call_hooks_integration.yaml @@ -0,0 +1,108 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Say hello"}],"model":"gpt-4o-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '74' + content-type: + - application/json + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 1.83.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFKxbtswEN31FVfOViEbRix76RAUcIaiQzu1CASaPMlMSR5Bnhobgf+9 + oJRYSpMCXTjcu/f43t09FQDCaLEDoY6SlQu2vDXu+5f2HNeeTubuVDl3Wu6/Pn7ufuy/HcQiM+jw + gIpfWB8VuWCRDfkRVhElY1Zdbm7Wm7rebG8GwJFGm2ld4HJNpTPelKtqtS6rTbmsn9lHMgqT2MHP + AgDgaXizT6/xJHZQLV4qDlOSHYrdtQlARLK5ImRKJrH0LBYTqMgz+sH6Hq2lD7CnR1DSwx2MBDhT + D0xanj/NiRHbPsls3vfWzgDpPbHM4QfL98/I5WrSUhciHdJfVNEab9KxiSgT+WwoMQUxoJcC4H4Y + Rv8qnwiRXOCG6RcO321HNTFt4C3GxNJO5WW9eEer0cjS2DQbpVBSHVFPzGnusteGZkAxS/zWy3va + Y2rju/+RnwClMDDqJkTURr3OO7VFzOf5r7brhAfDImH8bRQ2bDDmLWhsZW/HoxHpnBhd0xrfYQzR + jJfThgaVrBTWq20tikvxBwAA//8DAFoGAGtHAwAA + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 03 Dec 2025 19:06:36 GMT + Server: + - cloudflare + Set-Cookie: + - SET-COOKIE-XXX + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '371' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '387' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/hooks/TestLLMHooksIntegration.test_lite_agent_hooks_integration_with_real_llm.yaml b/lib/crewai/tests/cassettes/hooks/TestLLMHooksIntegration.test_lite_agent_hooks_integration_with_real_llm.yaml new file mode 100644 index 000000000..c0aa3fa16 --- /dev/null +++ b/lib/crewai/tests/cassettes/hooks/TestLLMHooksIntegration.test_lite_agent_hooks_integration_with_real_llm.yaml @@ -0,0 +1,115 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Test Assistant. You are + a helpful test assistant\nYour personal goal is: Answer questions briefly\n\nTo + give my best complete final answer to the task respond using the exact following + format:\n\nThought: I now can give a great answer\nFinal Answer: Your final + answer must be the great and the most complete as possible, it must be outcome + described.\n\nI MUST use these formats, my job depends on it!"},{"role":"user","content":"Say + ''Hello World'' and nothing else"}],"model":"gpt-4.1-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '540' + content-type: + - application/json + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 1.83.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFLBbtQwEL3nK0Y+b6okZJuSW4VAFIq4rFQkqCLXmSQDjseynRZU7b8j + Z7ebFIrEJVLmzXt+b2YeEwBBrahBqEEGNVqdvqFxd331hW+2hD5scyrd5asPxe7j9edPb8UmMvju + O6rwxDpTPFqNgdgcYOVQBoyqeXVeVhcX1evtDIzcoo603oa0PMvTkQylRVZs06xM8/JIH5gUelHD + 1wQA4HH+RqOmxZ+ihmzzVBnRe9mjqE9NAMKxjhUhvScfpAlis4CKTUAze98NPPVDqOEKDD+AkgZ6 + ukeQ0McAII1/QPfNvCMjNVzOfzW8R60Zbtjpdq3rsJu8jOHMpPUKkMZwkHE4c6LbI7I/ZdDcW8d3 + /g+q6MiQHxqH0rOJfn1gK2Z0nwDczrOansUX1vFoQxP4B87P5Vlx0BPLjlbo9ggGDlKv6nm1eUGv + aTFI0n41baGkGrBdqMtq5NQSr4BklfpvNy9pH5KT6f9HfgGUQhuwbazDltTzxEubw3jC/2o7TXk2 + LDy6e1LYBEIXN9FiJyd9uCvhf/mAY9OR6dFZR4fj6mxTlFWeqarLzkWyT34DAAD//wMAP95PRmsD + AAA= + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 03 Dec 2025 19:06:36 GMT + Server: + - cloudflare + Set-Cookie: + - SET-COOKIE-XXX + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '620' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '1891' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/hooks/TestToolHooksIntegration.test_lite_agent_hooks_integration_with_real_tool.yaml b/lib/crewai/tests/cassettes/hooks/TestToolHooksIntegration.test_lite_agent_hooks_integration_with_real_tool.yaml new file mode 100644 index 000000000..a07f69ec6 --- /dev/null +++ b/lib/crewai/tests/cassettes/hooks/TestToolHooksIntegration.test_lite_agent_hooks_integration_with_real_tool.yaml @@ -0,0 +1,486 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Calculator Assistant. + You are a helpful calculator assistant\nYour personal goal is: Help with math + calculations\n\nYou ONLY have access to the following tools, and should NEVER + make up tools that are not listed here:\n\nTool Name: calculate_sum\nTool Arguments: + {''a'': {''description'': None, ''type'': ''int''}, ''b'': {''description'': + None, ''type'': ''int''}}\nTool Description: Add two numbers together.\n\nIMPORTANT: + Use the following format in your response:\n\n```\nThought: you should always + think about what to do\nAction: the action to take, only one name of [calculate_sum], + just the name, exactly as it''s written.\nAction Input: the input to the action, + just a simple JSON object, enclosed in curly braces, using \" to wrap keys and + values.\nObservation: the result of the action\n```\n\nOnce all necessary information + is gathered, return the following format:\n\n```\nThought: I now know the final + answer\nFinal Answer: the final answer to the original input question\n```"},{"role":"user","content":"What + is 5 + 3? Use the calculate_sum tool."}],"model":"gpt-4.1-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '1119' + content-type: + - application/json + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 1.83.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFPLbtswELzrKxY8W0Zsy3ajWxv0EbSXFm0CtAokmlxLTCiSIFdFWsP/ + XlB+SO4D6IUQdnZGs7PkLgFgSrIcmGg4idbp9EY9hbvZsrn7cP/l0/uv77JXzx9fv31UN2/u3U82 + iQy7eURBJ9ZU2NZpJGXNARYeOWFUna1X2frFfLVa90BrJepIqx2l2XSWtsqodH41X6ZXWTrLjvTG + KoGB5fAtAQDY9Wc0aiQ+sxyuJqdKiyHwGll+bgJg3upYYTwEFYgbYpMBFNYQmt57VVWF+dzYrm4o + h1sIje20hC4gUIMguBad5oRl6FogazWQBS4lLIEbCYtpYV6KOHV+2Xsqw61xHeWwKxgvWA7LCRRs + E78W+8JUVTX25XHbBR7DMZ3WI4AbY4lHvT6RhyOyP2egbe283YTfqGyrjApN6ZEHa+K8gaxjPbpP + AB76rLuL+JjztnVUkn3C/nfzRXbQY8OOBzQ7LoKRJa5HrPWJdaFXSiSudBhtiwkuGpQDdVgt76Sy + IyAZTf2nm79pHyZXpv4f+QEQAh2hLJ1HqcTlxEObx/gE/tV2Trk3zAL670pgSQp93ITELe/04V6y + 8CMQtuVWmRq98+pwObeuvF6vVrjMrjdzluyTXwAAAP//AwAHWkpkqwMAAA== + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 03 Dec 2025 17:24:28 GMT + Server: + - cloudflare + Set-Cookie: + - SET-COOKIE-XXX + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '681' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '871' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"system","content":"You are Calculator Assistant. + You are a helpful calculator assistant\nYour personal goal is: Help with math + calculations\n\nYou ONLY have access to the following tools, and should NEVER + make up tools that are not listed here:\n\nTool Name: calculate_sum\nTool Arguments: + {''a'': {''description'': None, ''type'': ''int''}, ''b'': {''description'': + None, ''type'': ''int''}}\nTool Description: Add two numbers together.\n\nIMPORTANT: + Use the following format in your response:\n\n```\nThought: you should always + think about what to do\nAction: the action to take, only one name of [calculate_sum], + just the name, exactly as it''s written.\nAction Input: the input to the action, + just a simple JSON object, enclosed in curly braces, using \" to wrap keys and + values.\nObservation: the result of the action\n```\n\nOnce all necessary information + is gathered, return the following format:\n\n```\nThought: I now know the final + answer\nFinal Answer: the final answer to the original input question\n```"},{"role":"user","content":"What + is 5 + 3? Use the calculate_sum tool."},{"role":"assistant","content":"```\nThought: + I should use the calculate_sum tool to add 5 and 3.\nAction: calculate_sum\nAction + Input: {\"a\": 5, \"b\": 3}\n```\nObservation: 8"}],"model":"gpt-4.1-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '1298' + content-type: + - application/json + cookie: + - COOKIE-XXX + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 1.83.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFJNb5wwEL3zKyyfl2ghBAi3KtJKTS9VlX5I3QiMGcBZY7v20DSK9r9X + hs1C2lTqxdLMm/c8b2aeA0KoaGhBKO8Z8sHI8EYc3Ff7pX663Y32w82Qffv46XNUR+Yu+7GjG8/Q + 9QNwfGFdcD0YCSi0mmFugSF41ShLkyyP0zSfgEE3ID2tMxgmF1E4CCXCeBtfhdskjJITvdeCg6MF + +R4QQsjz9PpGVQO/aEG2m5fMAM6xDmhxLiKEWi19hjLnhEOmkG4WkGuFoKbeq6raq7tej12PBXlP + lH4kB/9gD6QViknClHsEu1e7KXo3RQXJ96qqqrWqhXZ0zFtTo5QrgCmlkfnRTH7uT8jx7EDqzlhd + uz+otBVKuL60wJxWvluH2tAJPQaE3E+TGl+Zp8bqwWCJ+gDTd3F+OevRZUMLGuUnEDUyueQvt9Hm + Db2yAWRCutWsKWe8h2ahLothYyP0CghWrv/u5i3t2blQ3f/ILwDnYBCa0lhoBH/teCmz4A/4X2Xn + KU8NUwf2p+BQogDrN9FAy0Y5XxV1Tw5hKFuhOrDGivm0WlNeZ2kKV8l1HdPgGPwGAAD//wMAJksH + jGkDAAA= + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 03 Dec 2025 17:24:29 GMT + Server: + - cloudflare + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '427' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '442' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"system","content":"You are Calculator Assistant. + You are a helpful calculator assistant\nYour personal goal is: Help with math + calculations\n\nYou ONLY have access to the following tools, and should NEVER + make up tools that are not listed here:\n\nTool Name: calculate_sum\nTool Arguments: + {''a'': {''description'': None, ''type'': ''int''}, ''b'': {''description'': + None, ''type'': ''int''}}\nTool Description: Add two numbers together.\n\nIMPORTANT: + Use the following format in your response:\n\n```\nThought: you should always + think about what to do\nAction: the action to take, only one name of [calculate_sum], + just the name, exactly as it''s written.\nAction Input: the input to the action, + just a simple JSON object, enclosed in curly braces, using \" to wrap keys and + values.\nObservation: the result of the action\n```\n\nOnce all necessary information + is gathered, return the following format:\n\n```\nThought: I now know the final + answer\nFinal Answer: the final answer to the original input question\n```"},{"role":"user","content":"What + is 5 + 3? Use the calculate_sum tool."}],"model":"gpt-4.1-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '1119' + content-type: + - application/json + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 1.83.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jJLLbtswEEX3+ooB15bhh2zH2iXtJovuYqAPBRJNjiU6FEmQo7ap4X8v + JDmW3KZAN1rMmXs1c4enCIApyVJgouIkaqfjD6r+fFf9+KJ2u6/qeO92H1/mrw+ffj25h+OWTVqF + 3R9R0JtqKmztNJKypsfCIydsXeebdbK5286Wsw7UVqJuZaWjOJnO41oZFS9mi1U8S+J5cpFXVgkM + LIVvEQDAqfu2gxqJP1kKnVlXqTEEXiJLr00AzFvdVhgPQQXihthkgMIaQtPNXhRFZp4q25QVpfAI + BlECWeBSwgq4kbCEJihTAlUIgmvRaE6Yh6YGslZPM3Mv2q3TW/hWhkfjGkrhlDGesXQ1ydg+Y+ny + nJmiKMZDeTw0gbfJmEbrEeDGWOKtWRfH84WcrwFoWzpv9+EPKTsoo0KVe+TBmnbZQNaxjp4jgOcu + 6OYmO+a8rR3lZF+w+91imfR+bDjwQJebCyRLXI9Um/nkHb9cInGlw+hUTHBRoRykw115I5UdgWi0 + 9d/TvOfdb65M+T/2AxACHaHMnUepxO3GQ5vH9v3/q+2acjcwC+i/K4E5KfTtJSQeeKP7R8nCayCs + 84MyJXrnVf8yDy7fbtZrXCXb/YJF5+g3AAAA//8DANrSB6yoAwAA + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 03 Dec 2025 19:10:33 GMT + Server: + - cloudflare + Set-Cookie: + - SET-COOKIE-XXX + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '2329' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '2349' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"system","content":"You are Calculator Assistant. + You are a helpful calculator assistant\nYour personal goal is: Help with math + calculations\n\nYou ONLY have access to the following tools, and should NEVER + make up tools that are not listed here:\n\nTool Name: calculate_sum\nTool Arguments: + {''a'': {''description'': None, ''type'': ''int''}, ''b'': {''description'': + None, ''type'': ''int''}}\nTool Description: Add two numbers together.\n\nIMPORTANT: + Use the following format in your response:\n\n```\nThought: you should always + think about what to do\nAction: the action to take, only one name of [calculate_sum], + just the name, exactly as it''s written.\nAction Input: the input to the action, + just a simple JSON object, enclosed in curly braces, using \" to wrap keys and + values.\nObservation: the result of the action\n```\n\nOnce all necessary information + is gathered, return the following format:\n\n```\nThought: I now know the final + answer\nFinal Answer: the final answer to the original input question\n```"},{"role":"user","content":"What + is 5 + 3? Use the calculate_sum tool."},{"role":"assistant","content":"```\nThought: + I need to add 5 and 3 using the calculate_sum tool.\nAction: calculate_sum\nAction + Input: {\"a\":5,\"b\":3}\n```\nObservation: 8"}],"model":"gpt-4.1-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '1295' + content-type: + - application/json + cookie: + - COOKIE-XXX + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 1.83.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jJJNb9QwEIbv+RWWz5tqN81+5QaVQHDi0AOIrRKvPUncOmNjTyhQ7X9H + zn4kS0HiYsl+5h3POzMvCWNcK14wLltBsnMmvdPd57f+yzKovc/cR/z23ohfn+69yJX1fBYVdv8I + ks6qG2k7Z4C0xSOWHgRBzLpYr/L1Zju/vR1AZxWYKGscpfnNIu006jSbZ8t0nqeL/CRvrZYQeMG+ + Jowx9jKcsVBU8IMXbD47v3QQgmiAF5cgxri3Jr5wEYIOJJD4bITSIgEOtVdVtcP71vZNSwX7wNA+ + s6d4UAus1igMExiewe/w3XB7M9wKttlhVVXTrB7qPohoDXtjJkAgWhKxNYOfhxM5XBwY2zhv9+EP + Ka816tCWHkSwGKsNZB0f6CFh7GHoVH9lnjtvO0cl2ScYvss2p07xcUIjXWxOkCwJM1Ftz+AqX6mA + hDZh0msuhWxBjdJxMKJX2k5AMnH9upq/5T4619j8T/oRSAmOQJXOg9Ly2vEY5iEu8L/CLl0eCuYB + /HctoSQNPk5CQS16c9wqHn4Ggq6sNTbgndfH1apduV2vVrDMt/uMJ4fkNwAAAP//AwCRC7shaQMA + AA== + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Wed, 03 Dec 2025 19:10:35 GMT + Server: + - cloudflare + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '1647' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '1694' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/hooks/test_llm_hooks.py b/lib/crewai/tests/hooks/test_llm_hooks.py index 7d4562a30..60d28f687 100644 --- a/lib/crewai/tests/hooks/test_llm_hooks.py +++ b/lib/crewai/tests/hooks/test_llm_hooks.py @@ -309,3 +309,188 @@ class TestLLMHooksIntegration: clear_all_llm_call_hooks() hooks = get_before_llm_call_hooks() assert len(hooks) == 0 + + @pytest.mark.vcr() + def test_lite_agent_hooks_integration_with_real_llm(self): + """Test that LiteAgent executes before/after LLM call hooks and prints messages correctly.""" + import os + from crewai.lite_agent import LiteAgent + + # Skip if no API key available + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY not set - skipping real LLM test") + + # Track hook invocations + hook_calls = {"before": [], "after": []} + + def before_llm_call_hook(context: LLMCallHookContext) -> bool: + """Log and verify before hook execution.""" + print(f"\n[BEFORE HOOK] Agent: {context.agent.role if context.agent else 'None'}") + print(f"[BEFORE HOOK] Iterations: {context.iterations}") + print(f"[BEFORE HOOK] Message count: {len(context.messages)}") + print(f"[BEFORE HOOK] Messages: {context.messages}") + + # Track the call + hook_calls["before"].append({ + "iterations": context.iterations, + "message_count": len(context.messages), + "has_task": context.task is not None, + "has_crew": context.crew is not None, + }) + + return True # Allow execution + + def after_llm_call_hook(context: LLMCallHookContext) -> str | None: + """Log and verify after hook execution.""" + print(f"\n[AFTER HOOK] Agent: {context.agent.role if context.agent else 'None'}") + print(f"[AFTER HOOK] Iterations: {context.iterations}") + print(f"[AFTER HOOK] Response: {context.response[:100] if context.response else 'None'}...") + print(f"[AFTER HOOK] Final message count: {len(context.messages)}") + + # Track the call + hook_calls["after"].append({ + "iterations": context.iterations, + "has_response": context.response is not None, + "response_length": len(context.response) if context.response else 0, + }) + + # Optionally modify response + if context.response: + return f"[HOOKED] {context.response}" + return None + + # Register hooks + register_before_llm_call_hook(before_llm_call_hook) + register_after_llm_call_hook(after_llm_call_hook) + + try: + # Create LiteAgent + lite_agent = LiteAgent( + role="Test Assistant", + goal="Answer questions briefly", + backstory="You are a helpful test assistant", + verbose=True, + ) + + # Verify hooks are loaded + assert len(lite_agent.before_llm_call_hooks) > 0, "Before hooks not loaded" + assert len(lite_agent.after_llm_call_hooks) > 0, "After hooks not loaded" + + # Execute with a simple prompt + result = lite_agent.kickoff("Say 'Hello World' and nothing else") + + + # Verify hooks were called + assert len(hook_calls["before"]) > 0, "Before hook was never called" + assert len(hook_calls["after"]) > 0, "After hook was never called" + + # Verify context had correct attributes for LiteAgent (used in flows) + # LiteAgent doesn't have task/crew context, unlike agents in CrewBase + before_call = hook_calls["before"][0] + assert before_call["has_task"] is False, "Task should be None for LiteAgent in flows" + assert before_call["has_crew"] is False, "Crew should be None for LiteAgent in flows" + assert before_call["message_count"] > 0, "Should have messages" + + # Verify after hook received response + after_call = hook_calls["after"][0] + assert after_call["has_response"] is True, "After hook should have response" + assert after_call["response_length"] > 0, "Response should not be empty" + + # Verify response was modified by after hook + # Note: The hook modifies the raw LLM response, but LiteAgent then parses it + # to extract the "Final Answer" portion. We check the messages to see the modification. + assert len(result.messages) > 2, "Should have assistant message in messages" + last_message = result.messages[-1] + assert last_message["role"] == "assistant", "Last message should be from assistant" + assert "[HOOKED]" in last_message["content"], "Hook should have modified the assistant message" + + + finally: + # Clean up hooks + unregister_before_llm_call_hook(before_llm_call_hook) + unregister_after_llm_call_hook(after_llm_call_hook) + + @pytest.mark.vcr() + def test_direct_llm_call_hooks_integration(self): + """Test that hooks work for direct llm.call() without agents.""" + import os + from crewai.llm import LLM + + # Skip if no API key available + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY not set - skipping real LLM test") + + # Track hook invocations + hook_calls = {"before": [], "after": []} + + def before_hook(context: LLMCallHookContext) -> bool: + """Log and verify before hook execution.""" + print(f"\n[BEFORE HOOK] Agent: {context.agent}") + print(f"[BEFORE HOOK] Task: {context.task}") + print(f"[BEFORE HOOK] Crew: {context.crew}") + print(f"[BEFORE HOOK] LLM: {context.llm}") + print(f"[BEFORE HOOK] Iterations: {context.iterations}") + print(f"[BEFORE HOOK] Message count: {len(context.messages)}") + + # Track the call + hook_calls["before"].append({ + "agent": context.agent, + "task": context.task, + "crew": context.crew, + "llm": context.llm is not None, + "message_count": len(context.messages), + }) + + return True # Allow execution + + def after_hook(context: LLMCallHookContext) -> str | None: + """Log and verify after hook execution.""" + print(f"\n[AFTER HOOK] Agent: {context.agent}") + print(f"[AFTER HOOK] Response: {context.response[:100] if context.response else 'None'}...") + + # Track the call + hook_calls["after"].append({ + "has_response": context.response is not None, + "response_length": len(context.response) if context.response else 0, + }) + + # Modify response + if context.response: + return f"[HOOKED] {context.response}" + return None + + # Register hooks + register_before_llm_call_hook(before_hook) + register_after_llm_call_hook(after_hook) + + try: + # Create LLM and make direct call + llm = LLM(model="gpt-4o-mini") + result = llm.call([{"role": "user", "content": "Say hello"}]) + + print(f"\n[TEST] Final result: {result}") + + # Verify hooks were called + assert len(hook_calls["before"]) > 0, "Before hook was never called" + assert len(hook_calls["after"]) > 0, "After hook was never called" + + # Verify context had correct attributes for direct LLM calls + before_call = hook_calls["before"][0] + assert before_call["agent"] is None, "Agent should be None for direct LLM calls" + assert before_call["task"] is None, "Task should be None for direct LLM calls" + assert before_call["crew"] is None, "Crew should be None for direct LLM calls" + assert before_call["llm"] is True, "LLM should be present" + assert before_call["message_count"] > 0, "Should have messages" + + # Verify after hook received response + after_call = hook_calls["after"][0] + assert after_call["has_response"] is True, "After hook should have response" + assert after_call["response_length"] > 0, "Response should not be empty" + + # Verify response was modified by after hook + assert "[HOOKED]" in result, "Response should be modified by after hook" + + finally: + # Clean up hooks + unregister_before_llm_call_hook(before_hook) + unregister_after_llm_call_hook(after_hook) diff --git a/lib/crewai/tests/hooks/test_tool_hooks.py b/lib/crewai/tests/hooks/test_tool_hooks.py index ffc95fecb..e8c6bd7d0 100644 --- a/lib/crewai/tests/hooks/test_tool_hooks.py +++ b/lib/crewai/tests/hooks/test_tool_hooks.py @@ -496,3 +496,97 @@ class TestToolHooksIntegration: clear_all_tool_call_hooks() hooks = get_before_tool_call_hooks() assert len(hooks) == 0 + + @pytest.mark.vcr() + def test_lite_agent_hooks_integration_with_real_tool(self): + """Test that LiteAgent executes before/after tool call hooks with real tool calls.""" + import os + from crewai.lite_agent import LiteAgent + from crewai.tools import tool + + # Skip if no API key available + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY not set - skipping real tool test") + + # Track hook invocations + hook_calls = {"before": [], "after": []} + + # Create a simple test tool + @tool("calculate_sum") + def calculate_sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + def before_tool_call_hook(context: ToolCallHookContext) -> bool: + """Log and verify before hook execution.""" + print(f"\n[BEFORE HOOK] Tool: {context.tool_name}") + print(f"[BEFORE HOOK] Tool input: {context.tool_input}") + print(f"[BEFORE HOOK] Agent: {context.agent.role if context.agent else 'None'}") + print(f"[BEFORE HOOK] Task: {context.task}") + print(f"[BEFORE HOOK] Crew: {context.crew}") + + # Track the call + hook_calls["before"].append({ + "tool_name": context.tool_name, + "tool_input": context.tool_input, + "has_agent": context.agent is not None, + "has_task": context.task is not None, + "has_crew": context.crew is not None, + }) + + return True # Allow execution + + def after_tool_call_hook(context: ToolCallHookContext) -> str | None: + """Log and verify after hook execution.""" + print(f"\n[AFTER HOOK] Tool: {context.tool_name}") + print(f"[AFTER HOOK] Tool result: {context.tool_result}") + print(f"[AFTER HOOK] Agent: {context.agent.role if context.agent else 'None'}") + + # Track the call + hook_calls["after"].append({ + "tool_name": context.tool_name, + "tool_result": context.tool_result, + "has_result": context.tool_result is not None, + }) + + return None # Don't modify result + + # Register hooks + register_before_tool_call_hook(before_tool_call_hook) + register_after_tool_call_hook(after_tool_call_hook) + + try: + # Create LiteAgent with the tool + lite_agent = LiteAgent( + role="Calculator Assistant", + goal="Help with math calculations", + backstory="You are a helpful calculator assistant", + tools=[calculate_sum], + verbose=True, + ) + + # Execute with a prompt that should trigger tool usage + result = lite_agent.kickoff("What is 5 + 3? Use the calculate_sum tool.") + + # Verify hooks were called + assert len(hook_calls["before"]) > 0, "Before hook was never called" + assert len(hook_calls["after"]) > 0, "After hook was never called" + + # Verify context had correct attributes for LiteAgent (used in flows) + # LiteAgent doesn't have task/crew context, unlike agents in CrewBase + before_call = hook_calls["before"][0] + assert before_call["tool_name"] == "calculate_sum", "Tool name should be 'calculate_sum'" + assert "a" in before_call["tool_input"], "Tool input should have 'a' parameter" + assert "b" in before_call["tool_input"], "Tool input should have 'b' parameter" + + # Verify after hook received result + after_call = hook_calls["after"][0] + assert after_call["has_result"] is True, "After hook should have tool result" + assert after_call["tool_name"] == "calculate_sum", "Tool name should match" + # The result should contain the sum (8) + assert "8" in str(after_call["tool_result"]), "Tool result should contain the sum" + + finally: + # Clean up hooks + unregister_before_tool_call_hook(before_tool_call_hook) + unregister_after_tool_call_hook(after_tool_call_hook)