Files
crewAI/lib/crewai/src/crewai/utilities/agent_utils.py
Joao Moura 4362a45dea fix(tools): stop rewriting the authored tool description at construction
BaseTool.model_post_init silently replaced the public description field
with the LLM-facing composite ("Tool Name: ...\nTool Arguments: ...\n
Tool Description: <authored>"), breaking equality assertions on authored
text and hiding the extra prompt tokens from token-careful authors.

The authored description now survives construction as written. The
composite is composed on demand via a new formatted_description property
on BaseTool and CrewStructuredTool (shared format_description_for_llm
helper), and every prompt path that relied on the baked-in composite —
render_text_description_and_args, ToolUsage._render, and tool-usage
error messages — now renders through it, so the text the LLM sees is
unchanged.

The helper strips any pre-existing composite block before composing, so
tools deserialized from old checkpoints and adapters that still bake the
composite into the field (e.g. the crewai-tools MCP adapter) don't get
double-wrapped. BaseTool._generate_description remains as a no-op hook
because subclasses override it and model_post_init still calls it.

Fixes EPD-179.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:26:44 -07:00

1798 lines
59 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterator, Sequence
import concurrent.futures
import contextlib
import contextvars
from dataclasses import dataclass, field
from datetime import datetime
import inspect
import json
import re
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict
from crewai_core.printer import PRINTER, ColoredText, Printer
from crewai_core.settings import Settings
from pydantic import BaseModel
from rich.console import Console
from crewai.agents.constants import FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE
from crewai.agents.parser import (
AgentAction,
AgentFinish,
OutputParserError,
parse,
)
from crewai.llms.base_llm import BaseLLM, call_stop_override
from crewai.tools import BaseTool as CrewAITool
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_types import ToolResult
from crewai.utilities.errors import AgentRepositoryError
from crewai.utilities.exceptions.context_window_exceeding_exception import (
LLMContextLengthExceededError,
)
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.pydantic_schema_utils import generate_model_description
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.token_counter_callback import TokenCalcHandler
from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.agents.crew_agent_executor import CrewAgentExecutor
from crewai.agents.tools_handler import ToolsHandler
from crewai.experimental.agent_executor import AgentExecutor
from crewai.lite_agent import LiteAgent
from crewai.llm import LLM
from crewai.task import Task
_create_plus_client_hook: Callable[[], Any] | None = None
class SummaryContent(TypedDict):
"""Structure for summary content entries.
Attributes:
content: The summarized content.
"""
content: str
console = Console()
_MULTIPLE_NEWLINES: Final[re.Pattern[str]] = re.compile(r"\n+")
_NATIVE_TOOL_UNSUPPORTED_PATTERNS: Final[tuple[str, ...]] = (
"does not support tools",
"doesn't support tools",
"tools are not supported",
"tool calling is not supported",
"tool calls are not supported",
"function calling is not supported",
"does not support function calling",
)
def is_inside_event_loop() -> bool:
"""Check if code is currently running inside an asyncio event loop.
This is used to detect when code is being called from within an async context
(e.g., inside a Flow). In such cases, callers should return a coroutine
instead of executing synchronously to avoid nested event loop errors.
Returns:
True if inside a running event loop, False otherwise.
"""
try:
asyncio.get_running_loop()
return True
except RuntimeError:
return False
def parse_tools(tools: list[BaseTool]) -> list[CrewStructuredTool]:
"""Parse tools to be used for the task.
Args:
tools: List of tools to parse.
Returns:
List of structured tools.
Raises:
ValueError: If a tool is not a CrewStructuredTool or BaseTool.
"""
tools_list: list[CrewStructuredTool] = []
for tool in tools:
if isinstance(tool, CrewAITool):
structured_tool = tool.to_structured_tool()
structured_tool.current_usage_count = 0
if structured_tool._original_tool:
structured_tool._original_tool.current_usage_count = 0
tools_list.append(structured_tool)
else:
raise ValueError("Tool is not a CrewStructuredTool or BaseTool")
return tools_list
def get_tool_names(tools: Sequence[CrewStructuredTool | BaseTool]) -> str:
"""Get the sanitized names of the tools.
Args:
tools: List of tools to get names from.
Returns:
Comma-separated string of sanitized tool names.
"""
return ", ".join([sanitize_tool_name(t.name) for t in tools])
def render_text_description_and_args(
tools: Sequence[CrewStructuredTool | BaseTool],
) -> str:
"""Render the tool name, description, and args in plain text.
search: This tool is used for search, args: {"query": {"type": "string"}}
calculator: This tool is used for math, \
args: {"expression": {"type": "string"}}
Args:
tools: List of tools to render.
Returns:
Plain text description of tools.
"""
tool_strings = [tool.formatted_description for tool in tools]
return "\n".join(tool_strings)
def convert_tools_to_openai_schema(
tools: Sequence[BaseTool | CrewStructuredTool],
) -> tuple[
list[dict[str, Any]],
dict[str, Callable[..., Any]],
dict[str, BaseTool | CrewStructuredTool],
]:
"""Convert CrewAI tools to OpenAI function calling format.
This function converts CrewAI BaseTool and CrewStructuredTool objects
into the OpenAI-compatible tool schema format that can be passed to
LLM providers for native function calling.
Args:
tools: List of CrewAI tool objects to convert.
Returns:
Tuple containing:
- List of OpenAI-format tool schema dictionaries
- Dict mapping sanitized tool names to their callable run() methods
- Dict mapping sanitized tool names to their original tool objects
"""
openai_tools: list[dict[str, Any]] = []
available_functions: dict[str, Callable[..., Any]] = {}
tool_name_mapping: dict[str, BaseTool | CrewStructuredTool] = {}
for tool in tools:
parameters: dict[str, Any] = {}
if hasattr(tool, "args_schema") and tool.args_schema is not None:
try:
schema_output = generate_model_description(
tool.args_schema, strip_null_types=False
)
parameters = schema_output.get("json_schema", {}).get("schema", {})
parameters.pop("title", None)
parameters.pop("description", None)
except Exception:
parameters = {}
# BaseTool formats description as "Tool Name: ...\nTool Arguments: ...\nTool Description: {original}"
description = tool.description
if "Tool Description:" in description:
description = description.split("Tool Description:")[-1].strip()
sanitized_name = sanitize_tool_name(tool.name)
if sanitized_name in available_functions:
counter = 2
candidate = sanitize_tool_name(f"{sanitized_name}_{counter}")
while candidate in available_functions:
counter += 1
candidate = sanitize_tool_name(f"{sanitized_name}_{counter}")
sanitized_name = candidate
schema: dict[str, Any] = {
"type": "function",
"function": {
"name": sanitized_name,
"description": description,
"parameters": parameters,
"strict": True,
},
}
openai_tools.append(schema)
available_functions[sanitized_name] = tool.run # type: ignore[union-attr]
tool_name_mapping[sanitized_name] = tool
return openai_tools, available_functions, tool_name_mapping
def extract_task_section(text: str) -> str:
"""Extract the ## Task body from a structured enriched instruction.
For structured descriptions (e.g. with ## Task and ## Instructions sections),
extracts just the task body so the caller sees the requirements without
duplicating tool/verification instructions.
Falls back to the full text (up to 2000 chars, with a truncation marker)
for plain inputs.
"""
for marker in ("\n## Task\n", "\n## Task:", "## Task\n"):
idx = text.find(marker)
if idx >= 0:
start = idx + len(marker)
for end_marker in ("\n---\n", "\n## "):
end = text.find(end_marker, start)
if end > 0:
return text[start:end].strip()
return text[start : start + 2000].strip()
if len(text) > 2000:
return text[:2000] + "\n... [truncated]"
return text
def _executor_stop_words(
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
) -> list[str]:
"""Return the executor's stop words, regardless of which field name it uses."""
if executor_context is None:
return []
stops = getattr(executor_context, "stop", None)
if stops is None:
stops = getattr(executor_context, "stop_words", None)
return list(stops) if stops else []
@contextlib.contextmanager
def _llm_stop_words_applied(
llm: LLM | BaseLLM,
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
) -> Iterator[None]:
"""Apply the executor's stop words to the LLM for the duration of one call.
Uses :func:`crewai.llms.base_llm.call_stop_override` so the LLM's stop
field is never mutated. Safe under concurrent execution: the override is
propagated via a :class:`contextvars.ContextVar` and is scoped to this
call's task / thread context.
"""
extra = _executor_stop_words(executor_context)
if not extra or not isinstance(llm, BaseLLM) or set(extra).issubset(llm.stop):
yield
return
with call_stop_override(llm, list(set(llm.stop + extra))):
yield
def has_reached_max_iterations(iterations: int, max_iterations: int) -> bool:
"""Check if the maximum number of iterations has been reached.
Args:
iterations: Current number of iterations.
max_iterations: Maximum allowed iterations.
Returns:
True if maximum iterations reached, False otherwise.
"""
return iterations >= max_iterations
def handle_max_iterations_exceeded(
formatted_answer: AgentAction | AgentFinish | None,
printer: Printer,
messages: list[LLMMessage],
llm: LLM | BaseLLM,
callbacks: list[TokenCalcHandler],
verbose: bool = True,
) -> AgentFinish:
"""Handles the case when the maximum number of iterations is exceeded. Performs one more LLM call to get the final answer.
Args:
formatted_answer: The last formatted answer from the agent.
printer: Printer instance for output.
messages: List of messages to send to the LLM.
llm: The LLM instance to call.
callbacks: List of callbacks for the LLM call.
verbose: Whether to print output.
Returns:
AgentFinish with the final answer after exceeding max iterations.
"""
if verbose:
printer.print(
content="Maximum iterations reached. Requesting final answer.",
color="yellow",
)
if formatted_answer and hasattr(formatted_answer, "text"):
assistant_message = (
formatted_answer.text + f"\n{I18N_DEFAULT.errors('force_final_answer')}"
)
else:
assistant_message = I18N_DEFAULT.errors("force_final_answer")
messages.append(format_message_for_llm(assistant_message, role="assistant"))
answer = llm.call(
messages,
callbacks=callbacks,
)
if answer is None or answer == "":
if verbose:
printer.print(
content="Received None or empty response from LLM call.",
color="red",
)
raise ValueError("Invalid response from LLM call - None or empty.")
formatted = format_answer(answer=answer)
if isinstance(formatted, AgentFinish):
return formatted
return AgentFinish(
thought=formatted.thought,
output=formatted.text,
text=formatted.text,
)
def format_message_for_llm(
prompt: str, role: Literal["user", "assistant", "system"] = "user"
) -> LLMMessage:
"""Format a message for the LLM.
Args:
prompt: The message content.
role: The role of the message sender, either 'user' or 'assistant'.
Returns:
A dictionary with 'role' and 'content' keys.
"""
prompt = prompt.rstrip()
return {"role": role, "content": prompt}
def format_answer(answer: str) -> AgentAction | AgentFinish:
"""Format a response from the LLM into an AgentAction or AgentFinish.
Args:
answer: The raw response from the LLM
Returns:
Either an AgentAction or AgentFinish
"""
try:
return parse(answer)
except Exception:
return AgentFinish(
thought="Failed to parse LLM response",
output=answer,
text=answer,
)
def enforce_rpm_limit(
request_within_rpm_limit: Callable[[], bool] | None = None,
) -> None:
"""Enforce the requests per minute (RPM) limit if applicable.
Args:
request_within_rpm_limit: Function to enforce RPM limit.
"""
if request_within_rpm_limit:
request_within_rpm_limit()
def _prepare_llm_call(
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
messages: list[LLMMessage],
printer: Printer,
verbose: bool = True,
) -> list[LLMMessage]:
"""Shared pre-call logic: run before hooks and resolve messages.
Args:
executor_context: Optional executor context for hook invocation.
messages: The messages to send to the LLM.
printer: Printer instance for output.
verbose: Whether to print output.
Returns:
The resolved messages list (may come from executor_context).
Raises:
ValueError: If a before hook blocks the call.
"""
if executor_context is not None:
if not _setup_before_llm_call_hooks(executor_context, printer, verbose=verbose):
raise ValueError("LLM call blocked by before_llm_call hook")
messages = executor_context.messages
return messages
def _validate_and_finalize_llm_response(
answer: Any,
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
printer: Printer,
verbose: bool = True,
) -> str | BaseModel | Any:
"""Shared post-call logic: validate response and run after hooks.
Args:
answer: The raw LLM response.
executor_context: Optional executor context for hook invocation.
printer: Printer instance for output.
verbose: Whether to print output.
Returns:
The potentially modified response.
Raises:
ValueError: If the response is None or empty.
"""
if not answer:
if verbose:
printer.print(
content="Received None or empty response from LLM call.",
color="red",
)
raise ValueError("Invalid response from LLM call - None or empty.")
return _setup_after_llm_call_hooks(
executor_context, answer, printer, verbose=verbose
)
def get_llm_response(
llm: LLM | BaseLLM,
messages: list[LLMMessage],
callbacks: list[TokenCalcHandler],
printer: Printer,
tools: list[dict[str, Any]] | None = None,
available_functions: dict[str, Callable[..., Any]] | None = None,
from_task: Task | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None = None,
verbose: bool = True,
) -> str | BaseModel | Any:
"""Call the LLM and return the response, handling any invalid responses.
Args:
llm: The LLM instance to call.
messages: The messages to send to the LLM.
callbacks: List of callbacks for the LLM call.
printer: Printer instance for output.
tools: Optional list of tool schemas for native function calling.
available_functions: Optional dict mapping function names to callables.
from_task: Optional task context for the LLM call.
from_agent: Optional agent context for the LLM call.
response_model: Optional Pydantic model for structured outputs.
executor_context: Optional executor context for hook invocation.
verbose: Whether to print output.
Returns:
The response from the LLM as a string, Pydantic model (when response_model is provided),
or tool call results if native function calling is used.
Raises:
Exception: If an error occurs.
ValueError: If the response is None or empty.
"""
messages = _prepare_llm_call(executor_context, messages, printer, verbose=verbose)
answer = llm.call(
messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return _validate_and_finalize_llm_response(
answer, executor_context, printer, verbose=verbose
)
async def aget_llm_response(
llm: LLM | BaseLLM,
messages: list[LLMMessage],
callbacks: list[TokenCalcHandler],
printer: Printer,
tools: list[dict[str, Any]] | None = None,
available_functions: dict[str, Callable[..., Any]] | None = None,
from_task: Task | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
executor_context: CrewAgentExecutor | AgentExecutor | None = None,
verbose: bool = True,
) -> str | BaseModel | Any:
"""Call the LLM asynchronously and return the response.
Args:
llm: The LLM instance to call.
messages: The messages to send to the LLM.
callbacks: List of callbacks for the LLM call.
printer: Printer instance for output.
tools: Optional list of tool schemas for native function calling.
available_functions: Optional dict mapping function names to callables.
from_task: Optional task context for the LLM call.
from_agent: Optional agent context for the LLM call.
response_model: Optional Pydantic model for structured outputs.
executor_context: Optional executor context for hook invocation.
verbose: Whether to print output.
Returns:
The response from the LLM as a string, Pydantic model (when response_model is provided),
or tool call results if native function calling is used.
Raises:
Exception: If an error occurs.
ValueError: If the response is None or empty.
"""
messages = _prepare_llm_call(executor_context, messages, printer, verbose=verbose)
answer = await llm.acall(
messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return _validate_and_finalize_llm_response(
answer, executor_context, printer, verbose=verbose
)
def process_llm_response(
answer: str, use_stop_words: bool
) -> AgentAction | AgentFinish:
"""Process the LLM response and format it into an AgentAction or AgentFinish.
Args:
answer: The raw response from the LLM
use_stop_words: Whether to use stop words in the LLM call
Returns:
Either an AgentAction or AgentFinish
"""
if not use_stop_words:
try:
format_answer(answer)
except OutputParserError as e:
if FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE in e.error:
answer = answer.split("Observation:")[0].strip()
return format_answer(answer)
def handle_agent_action_core(
formatted_answer: AgentAction,
tool_result: ToolResult,
messages: list[LLMMessage] | None = None,
step_callback: Callable | None = None, # type: ignore[type-arg]
show_logs: Callable | None = None, # type: ignore[type-arg]
) -> AgentAction | AgentFinish:
"""Core logic for handling agent actions and tool results.
Args:
formatted_answer: The agent's action
tool_result: The result of executing the tool
messages: Optional list of messages to append results to
step_callback: Optional callback to execute after processing
show_logs: Optional function to show logs
Returns:
Either an AgentAction or AgentFinish
Notes:
- TODO: Remove messages parameter and its usage.
"""
if step_callback:
cb_result = step_callback(tool_result)
if inspect.iscoroutine(cb_result):
asyncio.run(cb_result)
formatted_answer.text += f"\nObservation: {tool_result.result}"
formatted_answer.result = tool_result.result
if tool_result.result_as_answer:
return AgentFinish(
thought="",
output=tool_result.result,
text=formatted_answer.text,
)
if show_logs:
show_logs(formatted_answer)
return formatted_answer
def handle_unknown_error(
printer: Printer, exception: Exception, verbose: bool = True
) -> None:
"""Handle unknown errors by informing the user.
Args:
printer: Printer instance for output
exception: The exception that occurred
verbose: Whether to print output.
"""
if not verbose:
return
error_message = str(exception)
if "litellm" in error_message:
return
printer.print(
content="An unknown error occurred. Please check the details below.",
color="red",
)
printer.print(
content=f"Error details: {error_message}",
color="red",
)
def handle_output_parser_exception(
e: OutputParserError,
messages: list[LLMMessage],
iterations: int,
log_error_after: int = 3,
printer: Printer | None = None,
verbose: bool = True,
) -> AgentAction:
"""Handle OutputParserError by updating messages and formatted_answer.
Args:
e: The OutputParserError that occurred
messages: List of messages to append to
iterations: Current iteration count
log_error_after: Number of iterations after which to log errors
printer: Optional printer instance for logging
Returns:
AgentAction: A formatted answer with the error
"""
messages.append({"role": "user", "content": e.error})
formatted_answer = AgentAction(
text=e.error,
tool="",
tool_input="",
thought="",
)
if verbose and iterations > log_error_after and printer:
printer.print(
content=f"Error parsing LLM output, agent will retry: {e.error}",
color="red",
)
return formatted_answer
def is_context_length_exceeded(exception: Exception) -> bool:
"""Check if the exception is due to context length exceeding.
Args:
exception: The exception to check
Returns:
bool: True if the exception is due to context length exceeding
"""
return LLMContextLengthExceededError(str(exception))._is_context_limit_error(
str(exception)
)
def handle_context_length(
respect_context_window: bool,
printer: Printer,
messages: list[LLMMessage],
llm: LLM | BaseLLM,
callbacks: list[TokenCalcHandler],
verbose: bool = True,
) -> None:
"""Handle context length exceeded by either summarizing or raising an error.
Args:
respect_context_window: Whether to respect context window
printer: Printer instance for output
messages: List of messages to summarize
llm: LLM instance for summarization
callbacks: List of callbacks for LLM
Raises:
SystemExit: If context length is exceeded and user opts not to summarize
"""
if respect_context_window:
if verbose:
printer.print(
content="Context length exceeded. Summarizing content to fit the model context window. Might take a while...",
color="yellow",
)
summarize_messages(
messages=messages, llm=llm, callbacks=callbacks, verbose=verbose
)
else:
if verbose:
printer.print(
content="Context length exceeded. Consider using smaller text or RAG tools from crewai_tools.",
color="red",
)
raise SystemExit(
"Context length exceeded and user opted not to summarize. Consider using smaller text or RAG tools from crewai_tools."
)
def _estimate_token_count(text: str) -> int:
"""Estimate token count using a conservative cross-provider heuristic.
Args:
text: The text to estimate tokens for.
Returns:
Estimated token count (roughly 1 token per 4 characters).
"""
return len(text) // 4
def _format_messages_for_summary(messages: list[LLMMessage]) -> str:
"""Format messages with role labels for summarization.
Skips system messages. Handles None content, tool_calls, and
multimodal content blocks.
Args:
messages: List of messages to format.
Returns:
Role-labeled conversation text.
"""
lines: list[str] = []
for msg in messages:
role = msg.get("role", "user")
if role == "system":
continue
content = msg.get("content")
if content is None:
tool_calls = msg.get("tool_calls")
if tool_calls:
tool_names = []
for tc in tool_calls:
func = tc.get("function", {})
name = (
func.get("name", "unknown")
if isinstance(func, dict)
else "unknown"
)
tool_names.append(name)
content = f"[Called tools: {', '.join(tool_names)}]"
else:
content = ""
elif isinstance(content, list):
text_parts = [
block.get("text", "")
for block in content
if isinstance(block, dict) and block.get("type") == "text"
]
content = " ".join(text_parts) if text_parts else "[multimodal content]"
if role == "assistant":
label = "[ASSISTANT]:"
elif role == "tool":
tool_name = msg.get("name", "unknown")
label = f"[TOOL_RESULT ({tool_name})]:"
else:
label = "[USER]:"
lines.append(f"{label} {content}")
return "\n\n".join(lines)
def _split_messages_into_chunks(
messages: list[LLMMessage], max_tokens: int
) -> list[list[LLMMessage]]:
"""Split messages into chunks at message boundaries.
Excludes system messages from chunks. Each chunk stays under
max_tokens based on estimated token count.
Args:
messages: List of messages to split.
max_tokens: Maximum estimated tokens per chunk.
Returns:
List of message chunks.
"""
non_system = [m for m in messages if m.get("role") != "system"]
if not non_system:
return []
chunks: list[list[LLMMessage]] = []
current_chunk: list[LLMMessage] = []
current_tokens = 0
for msg in non_system:
content = msg.get("content")
if content is None:
msg_text = ""
elif isinstance(content, list):
msg_text = str(content)
else:
msg_text = str(content)
msg_tokens = _estimate_token_count(msg_text)
if current_chunk and (current_tokens + msg_tokens) > max_tokens:
chunks.append(current_chunk)
current_chunk = []
current_tokens = 0
current_chunk.append(msg)
current_tokens += msg_tokens
if current_chunk:
chunks.append(current_chunk)
return chunks
def _extract_summary_tags(text: str) -> str:
"""Extract content between <summary></summary> tags.
Falls back to the full text if no tags are found.
Args:
text: Text potentially containing summary tags.
Returns:
Extracted summary content, or full text if no tags found.
"""
match = re.search(r"<summary>(.*?)</summary>", text, re.DOTALL)
if match:
return match.group(1).strip()
return text.strip()
async def _asummarize_chunks(
chunks: list[list[LLMMessage]],
llm: LLM | BaseLLM,
callbacks: list[TokenCalcHandler],
) -> list[SummaryContent]:
"""Summarize multiple message chunks concurrently using asyncio.
Args:
chunks: List of message chunks to summarize.
llm: LLM instance (must support ``acall``).
callbacks: List of callbacks for the LLM.
Returns:
Ordered list of summary contents, one per chunk.
"""
async def _summarize_one(chunk: list[LLMMessage]) -> SummaryContent:
conversation_text = _format_messages_for_summary(chunk)
summarization_messages = [
format_message_for_llm(
I18N_DEFAULT.slice("summarizer_system_message"), role="system"
),
format_message_for_llm(
I18N_DEFAULT.slice("summarize_instruction").format(
conversation=conversation_text
),
),
]
summary = await llm.acall(summarization_messages, callbacks=callbacks)
extracted = _extract_summary_tags(str(summary))
return {"content": extracted}
results = await asyncio.gather(*[_summarize_one(chunk) for chunk in chunks])
return list(results)
def summarize_messages(
messages: list[LLMMessage],
llm: LLM | BaseLLM,
callbacks: list[TokenCalcHandler],
verbose: bool = True,
) -> None:
"""Summarize messages to fit within context window.
Uses structured context compaction: preserves system messages,
splits at message boundaries, formats with role labels, and
produces structured summaries for seamless task continuation.
Preserves any files attached to user messages and re-attaches them to
the summarized message. Files from all user messages are merged.
Args:
messages: List of messages to summarize (modified in-place)
llm: LLM instance for summarization
callbacks: List of callbacks for LLM
verbose: Whether to print progress.
"""
preserved_files: dict[str, Any] = {}
for msg in messages:
if msg.get("role") == "user" and msg.get("files"):
preserved_files.update(msg["files"])
system_messages = [m for m in messages if m.get("role") == "system"]
non_system_messages = [m for m in messages if m.get("role") != "system"]
if not non_system_messages:
return
max_tokens = llm.get_context_window_size()
chunks = _split_messages_into_chunks(non_system_messages, max_tokens)
total_chunks = len(chunks)
if total_chunks <= 1:
summarized_contents: list[SummaryContent] = []
for idx, chunk in enumerate(chunks, 1):
if verbose:
PRINTER.print(
content=f"Summarizing {idx}/{total_chunks}...",
color="yellow",
)
conversation_text = _format_messages_for_summary(chunk)
summarization_messages = [
format_message_for_llm(
I18N_DEFAULT.slice("summarizer_system_message"), role="system"
),
format_message_for_llm(
I18N_DEFAULT.slice("summarize_instruction").format(
conversation=conversation_text
),
),
]
summary = llm.call(summarization_messages, callbacks=callbacks)
extracted = _extract_summary_tags(str(summary))
summarized_contents.append({"content": extracted})
else:
if verbose:
PRINTER.print(
content=f"Summarizing {total_chunks} chunks in parallel...",
color="yellow",
)
coro = _asummarize_chunks(chunks=chunks, llm=llm, callbacks=callbacks)
if is_inside_event_loop():
ctx = contextvars.copy_context()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
summarized_contents = pool.submit(ctx.run, asyncio.run, coro).result()
else:
summarized_contents = asyncio.run(coro)
merged_summary = "\n\n".join(content["content"] for content in summarized_contents)
messages.clear()
messages.extend(system_messages)
summary_message = format_message_for_llm(
I18N_DEFAULT.slice("summary").format(merged_summary=merged_summary)
)
if preserved_files:
summary_message["files"] = preserved_files
messages.append(summary_message)
def show_agent_logs(
printer: Printer,
agent_role: str,
formatted_answer: AgentAction | AgentFinish | None = None,
task_description: str | None = None,
verbose: bool = False,
) -> None:
"""Show agent logs for both start and execution states.
Args:
printer: Printer instance for output
agent_role: Role of the agent
formatted_answer: Optional AgentAction or AgentFinish for execution logs
task_description: Optional task description for start logs
verbose: Whether to show verbose output
"""
if not verbose:
return
agent_role = agent_role.partition("\n")[0]
if formatted_answer is None:
printer.print(
content=[
ColoredText("# Agent: ", "bold_purple"),
ColoredText(agent_role, "bold_green"),
]
)
if task_description:
printer.print(
content=[
ColoredText("## Task: ", "purple"),
ColoredText(task_description, "green"),
]
)
else:
printer.print(
content=[
ColoredText("\n\n# Agent: ", "bold_purple"),
ColoredText(agent_role, "bold_green"),
]
)
if isinstance(formatted_answer, AgentAction):
thought = _MULTIPLE_NEWLINES.sub("\n", formatted_answer.thought)
formatted_json = json.dumps(
formatted_answer.tool_input,
indent=2,
ensure_ascii=False,
)
if thought and thought != "":
printer.print(
content=[
ColoredText("## Thought: ", "purple"),
ColoredText(thought, "green"),
]
)
printer.print(
content=[
ColoredText("## Using tool: ", "purple"),
ColoredText(formatted_answer.tool, "green"),
]
)
printer.print(
content=[
ColoredText("## Tool Input: ", "purple"),
ColoredText(f"\n{formatted_json}", "green"),
]
)
printer.print(
content=[
ColoredText("## Tool Output: ", "purple"),
ColoredText(f"\n{formatted_answer.result}", "green"),
]
)
elif isinstance(formatted_answer, AgentFinish):
printer.print(
content=[
ColoredText("## Final Answer: ", "purple"),
ColoredText(f"\n{formatted_answer.output}\n\n", "green"),
]
)
def _print_current_organization() -> None:
settings = Settings()
if settings.org_uuid:
console.print(
f"Fetching agent from organization: {settings.org_name} ({settings.org_uuid})",
style="bold blue",
)
else:
console.print(
"No organization currently set. We recommend setting one before using: `crewai org switch <org_id>` command.",
style="yellow",
)
def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
"""Load an agent from the repository.
Args:
from_repository: The name of the agent to load.
Returns:
A dictionary of attributes to use for the agent.
Raises:
AgentRepositoryError: If the agent cannot be loaded.
"""
attributes: dict[str, Any] = {}
if from_repository:
import importlib
if callable(_create_plus_client_hook):
client = _create_plus_client_hook()
else:
from crewai.auth.token import get_auth_token
from crewai.plus_api import PlusAPI
client = PlusAPI(api_key=get_auth_token())
_print_current_organization()
response = client.get_agent(from_repository)
if response.status_code == 404:
raise AgentRepositoryError(
f"Agent {from_repository} does not exist, make sure the name is correct or the agent is available on your organization."
f"\nIf you are using the wrong organization, switch to the correct one using `crewai org switch <org_id>` command.",
)
if response.status_code != 200:
raise AgentRepositoryError(
f"Agent {from_repository} could not be loaded: {response.text}"
f"\nIf you are using the wrong organization, switch to the correct one using `crewai org switch <org_id>` command.",
)
agent = response.json()
for key, value in agent.items():
if key == "tools":
attributes[key] = []
for tool in value:
try:
module = importlib.import_module(tool["module"])
tool_class = getattr(module, tool["name"])
tool_value = tool_class(**tool["init_params"])
if isinstance(tool_value, list):
attributes[key].extend(tool_value)
else:
attributes[key].append(tool_value)
except Exception as e: # noqa: PERF203
raise AgentRepositoryError(
f"Tool {tool['name']} could not be loaded: {e}"
) from e
elif key == "skills" and value == []:
continue
else:
attributes[key] = value
return attributes
DELEGATION_TOOL_NAMES: Final[frozenset[str]] = frozenset(
[
sanitize_tool_name("Delegate work to coworker"),
sanitize_tool_name("Ask question to coworker"),
]
)
def track_delegation_if_needed(
tool_name: str,
tool_args: dict[str, Any],
task: Task | None,
) -> None:
"""Track delegation if the tool is a delegation tool.
Args:
tool_name: Name of the tool being executed.
tool_args: Arguments passed to the tool.
task: The task being executed (used to track delegations).
"""
if sanitize_tool_name(tool_name) in DELEGATION_TOOL_NAMES and task is not None:
coworker = tool_args.get("coworker")
task.increment_delegations(coworker)
def extract_tool_call_info(
tool_call: Any,
) -> tuple[str, str, dict[str, Any] | str] | None:
"""Extract tool call ID, name, and arguments from various provider formats.
Args:
tool_call: The tool call object to extract info from.
Returns:
Tuple of (call_id, func_name, func_args) or None if format is unrecognized.
"""
if hasattr(tool_call, "function"):
# OpenAI-style: has .function.name and .function.arguments
call_id = getattr(tool_call, "id", f"call_{id(tool_call)}")
return (
call_id,
sanitize_tool_name(tool_call.function.name),
tool_call.function.arguments,
)
if hasattr(tool_call, "function_call") and tool_call.function_call:
# Gemini-style: has .function_call.name and .function_call.args
call_id = f"call_{id(tool_call)}"
return (
call_id,
sanitize_tool_name(tool_call.function_call.name),
dict(tool_call.function_call.args) if tool_call.function_call.args else {},
)
if hasattr(tool_call, "name") and hasattr(tool_call, "input"):
# Anthropic format: has .name and .input (ToolUseBlock)
call_id = getattr(tool_call, "id", f"call_{id(tool_call)}")
return call_id, sanitize_tool_name(tool_call.name), tool_call.input
if isinstance(tool_call, dict):
# Support OpenAI "id", Bedrock "toolUseId", or generate one
call_id = (
tool_call.get("id") or tool_call.get("toolUseId") or f"call_{id(tool_call)}"
)
func_info = tool_call.get("function", {})
func_name = func_info.get("name", "") or tool_call.get("name", "")
func_args = func_info.get("arguments") or tool_call.get("input") or {}
return call_id, sanitize_tool_name(func_name), func_args
return None
def is_tool_call_list(response: list[Any]) -> bool:
"""Check if a response from the LLM is a list of tool calls.
Supports OpenAI, Anthropic, Bedrock, and Gemini formats.
Args:
response: The response to check.
Returns:
True if the response appears to be a list of tool calls.
"""
if not response:
return False
first_item = response[0]
# OpenAI-style
if hasattr(first_item, "function") or (
isinstance(first_item, dict) and "function" in first_item
):
return True
# Anthropic-style (ToolUseBlock)
if hasattr(first_item, "type") and getattr(first_item, "type", None) == "tool_use":
return True
if hasattr(first_item, "name") and hasattr(first_item, "input"):
return True
# Bedrock-style
if isinstance(first_item, dict) and "name" in first_item and "input" in first_item:
return True
# Gemini-style
if hasattr(first_item, "function_call") and first_item.function_call:
return True
return False
def check_native_tool_support(llm: Any, original_tools: list[BaseTool] | None) -> bool:
"""Check if the LLM supports native function calling and tools are available.
Args:
llm: The LLM instance.
original_tools: Original BaseTool instances.
Returns:
True if native function calling is supported and tools exist.
"""
return (
hasattr(llm, "supports_function_calling")
and callable(getattr(llm, "supports_function_calling", None))
and llm.supports_function_calling()
and bool(original_tools)
)
def is_native_tool_calling_unsupported_error(error: BaseException) -> bool:
"""Return whether an error means native tool calling is unavailable."""
message = str(error).lower()
return any(pattern in message for pattern in _NATIVE_TOOL_UNSUPPORTED_PATTERNS)
def build_text_tool_calling_fallback_message(
tools_description: str,
tools_names: str,
) -> str:
"""Build instructions for downgrading native tools to text tool calls."""
text_tooling_prompt = I18N_DEFAULT.slice("tools").format(
tools=tools_description,
tool_names=tools_names,
)
return (
"Native tool calling is unavailable for this model/provider. "
"Continue using CrewAI text tool calling instead.\n"
f"{text_tooling_prompt}"
)
def setup_native_tools(
original_tools: list[BaseTool],
) -> tuple[
list[dict[str, Any]],
dict[str, Callable[..., Any]],
dict[str, BaseTool | CrewStructuredTool],
]:
"""Convert tools to OpenAI schema format for native function calling.
Args:
original_tools: Original BaseTool instances.
Returns:
Tuple of (openai_tools_schema, available_functions_dict, tool_name_mapping).
"""
return convert_tools_to_openai_schema(original_tools)
def build_tool_calls_assistant_message(
tool_calls: list[Any],
) -> tuple[LLMMessage | None, list[dict[str, Any]]]:
"""Build an assistant message containing tool call reports.
Extracts info from each tool call, builds the standard assistant message
format, and preserves raw Gemini parts when applicable.
Args:
tool_calls: Raw tool call objects from the LLM response.
Returns:
Tuple of (assistant_message, tool_calls_to_report).
assistant_message is None if no valid tool calls found.
"""
tool_calls_to_report: list[dict[str, Any]] = []
for tool_call in tool_calls:
info = extract_tool_call_info(tool_call)
if not info:
continue
call_id, func_name, func_args = info
tool_calls_to_report.append(
{
"id": call_id,
"type": "function",
"function": {
"name": func_name,
"arguments": func_args
if isinstance(func_args, str)
else json.dumps(func_args),
},
}
)
if not tool_calls_to_report:
return None, []
assistant_message: LLMMessage = {
"role": "assistant",
"content": None,
"tool_calls": tool_calls_to_report,
}
# Preserve raw parts for Gemini compatibility
if all(type(tc).__qualname__ == "Part" for tc in tool_calls):
assistant_message["raw_tool_call_parts"] = list(tool_calls)
return assistant_message, tool_calls_to_report
@dataclass
class NativeToolCallResult:
"""Result from executing a single native tool call."""
call_id: str
func_name: str
result: str
from_cache: bool = False
result_as_answer: bool = False
tool_message: LLMMessage = field(default_factory=dict) # type: ignore[assignment]
def format_native_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
"""Format native tool output when a tool explicitly defines a formatter."""
formatter = inspect.getattr_static(tool, "format_output_for_agent", None)
if formatter is None:
return str(raw_result)
runtime_formatter = getattr(tool, "format_output_for_agent", None)
if not callable(runtime_formatter):
return str(raw_result)
return str(runtime_formatter(raw_result))
def execute_single_native_tool_call(
tool_call: Any,
*,
available_functions: dict[str, Callable[..., Any]],
original_tools: list[BaseTool],
structured_tools: list[CrewStructuredTool] | None,
tools_handler: ToolsHandler | None,
agent: BaseAgent | None,
task: Task | None,
crew: Any | None,
event_source: Any,
printer: Printer | None = None,
verbose: bool = False,
plan_step_number: int | None = None,
plan_step_description: str | None = None,
) -> NativeToolCallResult:
"""Execute a single native tool call with full lifecycle management.
Handles: arg parsing, tool lookup, max-usage check, cache read/write,
before/after hooks, event emission, and result_as_answer detection.
Args:
tool_call: Raw tool call object from the LLM.
available_functions: Map of sanitized tool name -> callable.
original_tools: Original BaseTool list (for cache_function, result_as_answer).
structured_tools: Structured tools list (for hook context).
tools_handler: Optional handler with cache.
agent: The agent instance.
task: The current task.
crew: The crew instance.
event_source: The object to use as event emitter source.
printer: Optional printer for verbose logging.
verbose: Whether to print verbose output.
Returns:
NativeToolCallResult with all execution details.
"""
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
get_after_tool_call_hooks,
get_before_tool_call_hooks,
)
info = extract_tool_call_info(tool_call)
if not info:
return NativeToolCallResult(
call_id="", func_name="", result="Unrecognized tool call format"
)
call_id, func_name, func_args = info
if isinstance(func_args, str):
try:
args_dict = json.loads(func_args)
except json.JSONDecodeError:
args_dict = {}
else:
args_dict = func_args
agent_key = getattr(agent, "key", "unknown") if agent else "unknown"
original_tool: BaseTool | None = None
for tool in original_tools:
if sanitize_tool_name(tool.name) == func_name:
original_tool = tool
break
structured_tool: CrewStructuredTool | None = None
for structured in structured_tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
output_tool = original_tool or structured_tool
from_cache = False
input_str = json.dumps(args_dict) if args_dict else ""
result = "Tool not found"
raw_tool_result: Any = result
if tools_handler and tools_handler.cache and output_tool is not None:
cached_result = tools_handler.cache.read(tool=func_name, input=input_str)
if cached_result is not None:
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
started_at = datetime.now()
crewai_event_bus.emit(
event_source,
event=ToolUsageStartedEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=agent,
from_task=task,
agent_key=agent_key,
plan_step_number=plan_step_number,
plan_step_description=plan_step_description,
),
)
track_delegation_if_needed(func_name, args_dict, task)
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
agent=agent,
task=task,
crew=crew,
)
try:
for hook in get_before_tool_call_hooks():
if hook(before_hook_context) is False:
hook_blocked = True
break
except Exception: # noqa: S110
pass
error_event_emitted = False
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
raw_tool_result = result
elif not from_cache:
if func_name in available_functions and output_tool is not None:
try:
tool_func = available_functions[func_name]
raw_result = tool_func(**args_dict)
raw_tool_result = raw_result
if tools_handler and tools_handler.cache:
should_cache = True
if original_tool:
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
if task:
task.increment_tools_errors()
crewai_event_bus.emit(
event_source,
event=ToolUsageErrorEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=agent,
from_task=task,
agent_key=agent_key,
plan_step_number=plan_step_number,
plan_step_description=plan_step_description,
error=e,
),
)
error_event_emitted = True
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
agent=agent,
task=task,
crew=crew,
tool_result=result,
raw_tool_result=raw_tool_result,
)
try:
for after_hook in get_after_tool_call_hooks():
hook_result = after_hook(after_hook_context)
if hook_result is not None:
result = hook_result
after_hook_context.tool_result = result
except Exception: # noqa: S110
pass
if not error_event_emitted:
crewai_event_bus.emit(
event_source,
event=ToolUsageFinishedEvent(
output=result,
tool_name=func_name,
tool_args=args_dict,
from_agent=agent,
from_task=task,
agent_key=agent_key,
plan_step_number=plan_step_number,
plan_step_description=plan_step_description,
started_at=started_at,
finished_at=datetime.now(),
),
)
tool_message: LLMMessage = {
"role": "tool",
"tool_call_id": call_id,
"name": func_name,
"content": result,
}
if verbose and printer:
cache_info = " (from cache)" if from_cache else ""
printer.print(
content=f"Tool {func_name} executed with result{cache_info}: {result[:200]}...",
color="green",
)
is_result_as_answer = bool(
original_tool
and hasattr(original_tool, "result_as_answer")
and original_tool.result_as_answer
and not error_event_emitted
and not hook_blocked
)
return NativeToolCallResult(
call_id=call_id,
func_name=func_name,
result=result,
from_cache=from_cache,
result_as_answer=is_result_as_answer,
tool_message=tool_message,
)
def parse_tool_call_args(
func_args: dict[str, Any] | str,
func_name: str,
call_id: str,
original_tool: Any = None,
) -> tuple[dict[str, Any], None] | tuple[None, dict[str, Any]]:
"""Parse tool call arguments from a JSON string or dict.
Returns:
``(args_dict, None)`` on success, or ``(None, error_result)`` on
JSON parse failure where ``error_result`` is a ready-to-return dict
with the same shape as ``_execute_single_native_tool_call`` return values.
"""
if isinstance(func_args, str):
try:
return json.loads(func_args), None
except json.JSONDecodeError as e:
return None, {
"call_id": call_id,
"func_name": func_name,
"result": (
f"Error: Failed to parse tool arguments as JSON: {e}. "
f"Please provide valid JSON arguments for the '{func_name}' tool."
),
"from_cache": False,
"original_tool": original_tool,
}
return func_args, None
def _setup_before_llm_call_hooks(
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
printer: Printer,
verbose: bool = True,
) -> bool:
"""Setup and invoke before_llm_call hooks for the executor context.
Args:
executor_context: The executor context to setup the hooks for.
printer: Printer instance for error logging.
verbose: Whether to print output.
Returns:
True if LLM execution should proceed, False if blocked by a hook.
"""
if executor_context and executor_context.before_llm_call_hooks:
from crewai.hooks.llm_hooks import LLMCallHookContext
original_messages = executor_context.messages
hook_context = LLMCallHookContext(executor_context)
try:
for hook in executor_context.before_llm_call_hooks:
result = hook(hook_context)
if result is False:
if verbose:
printer.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
except Exception as e:
if verbose:
printer.print(
content=f"Error in before_llm_call hook: {e}",
color="yellow",
)
if not isinstance(executor_context.messages, list):
if verbose:
printer.print(
content=(
"Warning: before_llm_call hook replaced messages with non-list. "
"Restoring original messages list. Hooks should modify messages in-place, "
"not replace the list (e.g., use context.messages.append() not context.messages = [])."
),
color="yellow",
)
if isinstance(original_messages, list):
executor_context.messages = original_messages
else:
executor_context.messages = []
return True
def _setup_after_llm_call_hooks(
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
answer: str | BaseModel,
printer: Printer,
verbose: bool = True,
) -> str | BaseModel:
"""Setup and invoke after_llm_call hooks for the executor context.
Args:
executor_context: The executor context to setup the hooks for.
answer: The LLM response (string or Pydantic model).
printer: Printer instance for error logging.
verbose: Whether to print output.
Returns:
The potentially modified response (string or Pydantic model).
"""
if executor_context and executor_context.after_llm_call_hooks:
from crewai.hooks.llm_hooks import LLMCallHookContext
original_messages = executor_context.messages
if isinstance(answer, BaseModel):
pydantic_answer = answer
hook_response: str = pydantic_answer.model_dump_json()
original_json: str = hook_response
else:
pydantic_answer = None
hook_response = str(answer)
hook_context = LLMCallHookContext(executor_context, response=hook_response)
try:
for hook in executor_context.after_llm_call_hooks:
modified_response = hook(hook_context)
if modified_response is not None and isinstance(modified_response, str):
hook_response = modified_response
except Exception as e:
if verbose:
printer.print(
content=f"Error in after_llm_call hook: {e}",
color="yellow",
)
if not isinstance(executor_context.messages, list):
if verbose:
printer.print(
content=(
"Warning: after_llm_call hook replaced messages with non-list. "
"Restoring original messages list. Hooks should modify messages in-place, "
"not replace the list (e.g., use context.messages.append() not context.messages = [])."
),
color="yellow",
)
if isinstance(original_messages, list):
executor_context.messages = original_messages
else:
executor_context.messages = []
if pydantic_answer is not None:
if hook_response != original_json:
try:
model_class: type[BaseModel] = type(pydantic_answer)
answer = model_class.model_validate_json(hook_response)
except Exception as e:
if verbose:
printer.print(
content=f"Warning: Hook modified response but failed to reparse as {type(pydantic_answer).__name__}: {e}. Using original model.",
color="yellow",
)
else:
answer = hook_response
return answer