Lorenze/ensure hooks work with lite agents flows (#3981)

* liteagent support hooks

* wip llm.call hooks work - needs tests for this

* fix tests

* fixed more

* more tool hooks test cassettes
This commit is contained in:
Lorenze Jay
2025-12-04 09:38:39 -08:00
committed by GitHub
parent 633e279b51
commit c456e5c5fa
17 changed files with 1640 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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