diff --git a/docs/concepts/llms.mdx b/docs/concepts/llms.mdx index e17098f6a..2aada5fac 100644 --- a/docs/concepts/llms.mdx +++ b/docs/concepts/llms.mdx @@ -59,7 +59,7 @@ There are three ways to configure LLMs in CrewAI. Choose the method that best fi goal: Conduct comprehensive research and analysis backstory: A dedicated research professional with years of experience verbose: true - llm: openai/gpt-4o-mini # your model here + llm: openai/gpt-4o-mini # your model here # (see provider configuration examples below for more) ``` @@ -111,7 +111,7 @@ There are three ways to configure LLMs in CrewAI. Choose the method that best fi ## Provider Configuration Examples -CrewAI supports a multitude of LLM providers, each offering unique features, authentication methods, and model capabilities. +CrewAI supports a multitude of LLM providers, each offering unique features, authentication methods, and model capabilities. In this section, you'll find detailed examples that help you select, configure, and optimize the LLM that best fits your project's needs. @@ -121,7 +121,7 @@ In this section, you'll find detailed examples that help you select, configure, ```toml Code # Required OPENAI_API_KEY=sk-... - + # Optional OPENAI_API_BASE= OPENAI_ORGANIZATION= @@ -226,7 +226,7 @@ In this section, you'll find detailed examples that help you select, configure, AZURE_API_KEY= AZURE_API_BASE= AZURE_API_VERSION= - + # Optional AZURE_AD_TOKEN= AZURE_API_TYPE= @@ -289,7 +289,7 @@ In this section, you'll find detailed examples that help you select, configure, | Mistral 8x7B Instruct | Up to 32k tokens | An MOE LLM that follows instructions, completes requests, and generates creative text. | - + ```toml Code AWS_ACCESS_KEY_ID= @@ -474,7 +474,7 @@ In this section, you'll find detailed examples that help you select, configure, WATSONX_URL= WATSONX_APIKEY= WATSONX_PROJECT_ID= - + # Optional WATSONX_TOKEN= WATSONX_DEPLOYMENT_SPACE_ID= @@ -491,7 +491,7 @@ In this section, you'll find detailed examples that help you select, configure, 1. Install Ollama: [ollama.ai](https://ollama.ai/) - 2. Run a model: `ollama run llama2` + 2. Run a model: `ollama run llama3` 3. Configure: ```python Code @@ -600,7 +600,7 @@ In this section, you'll find detailed examples that help you select, configure, ```toml Code OPENROUTER_API_KEY= ``` - + Example usage in your CrewAI project: ```python Code llm = LLM( @@ -723,7 +723,7 @@ Learn how to get the most out of your LLM configuration: - Small tasks (up to 4K tokens): Standard models - Medium tasks (between 4K-32K): Enhanced models - Large tasks (over 32K): Large context models - + ```python # Configure model with appropriate settings llm = LLM( @@ -760,11 +760,11 @@ Learn how to get the most out of your LLM configuration: Most authentication issues can be resolved by checking API key format and environment variable names. - + ```bash # OpenAI OPENAI_API_KEY=sk-... - + # Anthropic ANTHROPIC_API_KEY=sk-ant-... ``` @@ -773,11 +773,11 @@ Learn how to get the most out of your LLM configuration: Always include the provider prefix in model names - + ```python # Correct llm = LLM(model="openai/gpt-4") - + # Incorrect llm = LLM(model="gpt-4") ``` @@ -786,5 +786,10 @@ Learn how to get the most out of your LLM configuration: Use larger context models for extensive tasks + + ```python + # Large context model + llm = LLM(model="openai/gpt-4o") # 128K tokens + ``` diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index df57f756f..1edccee0e 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -300,7 +300,7 @@ email_summarizer: ``` - Note how we use the same name for the agent in the `tasks.yaml` (`email_summarizer_task`) file as the method name in the `crew.py` (`email_summarizer_task`) file. + Note how we use the same name for the task in the `tasks.yaml` (`email_summarizer_task`) file as the method name in the `crew.py` (`email_summarizer_task`) file. ```yaml tasks.yaml diff --git a/pyproject.toml b/pyproject.toml index 2e319e8d0..6e895be32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,9 @@ dependencies = [ "pdfplumber>=0.11.4", "regex>=2024.9.11", # Telemetry and Monitoring - "opentelemetry-api>=1.22.0", - "opentelemetry-sdk>=1.22.0", - "opentelemetry-exporter-otlp-proto-http>=1.22.0", + "opentelemetry-api>=1.30.0", + "opentelemetry-sdk>=1.30.0", + "opentelemetry-exporter-otlp-proto-http>=1.30.0", # Data Handling "chromadb>=0.5.23", "openpyxl>=3.1.5", diff --git a/src/crewai/agents/agent_builder/base_agent.py b/src/crewai/agents/agent_builder/base_agent.py index 47515d087..4413e0a97 100644 --- a/src/crewai/agents/agent_builder/base_agent.py +++ b/src/crewai/agents/agent_builder/base_agent.py @@ -25,6 +25,7 @@ from crewai.tools.base_tool import BaseTool, Tool from crewai.utilities import I18N, Logger, RPMController from crewai.utilities.config import process_config from crewai.utilities.converter import Converter +from crewai.utilities.string_utils import interpolate_only T = TypeVar("T", bound="BaseAgent") @@ -333,9 +334,15 @@ class BaseAgent(ABC, BaseModel): self._original_backstory = self.backstory if inputs: - self.role = self._original_role.format(**inputs) - self.goal = self._original_goal.format(**inputs) - self.backstory = self._original_backstory.format(**inputs) + self.role = interpolate_only( + input_string=self._original_role, inputs=inputs + ) + self.goal = interpolate_only( + input_string=self._original_goal, inputs=inputs + ) + self.backstory = interpolate_only( + input_string=self._original_backstory, inputs=inputs + ) def set_cache_handler(self, cache_handler: CacheHandler) -> None: """Set the cache handler for the agent. diff --git a/src/crewai/agents/parser.py b/src/crewai/agents/parser.py index 05c5bc003..88a869c16 100644 --- a/src/crewai/agents/parser.py +++ b/src/crewai/agents/parser.py @@ -136,7 +136,7 @@ class CrewAgentParser: def _clean_action(self, text: str) -> str: """Clean action string by removing non-essential formatting characters.""" - return re.sub(r"^\s*\*+\s*|\s*\*+\s*$", "", text).strip() + return text.strip().strip("*").strip() def _safe_repair_json(self, tool_input: str) -> str: UNABLE_TO_REPAIR_JSON_RESULTS = ['""', "{}"] diff --git a/src/crewai/cli/git.py b/src/crewai/cli/git.py index 94c3648b0..58836e733 100644 --- a/src/crewai/cli/git.py +++ b/src/crewai/cli/git.py @@ -1,4 +1,5 @@ import subprocess +from functools import lru_cache class Repository: @@ -35,6 +36,7 @@ class Repository: encoding="utf-8", ).strip() + @lru_cache(maxsize=None) def is_git_repo(self) -> bool: """Check if the current directory is a git repository.""" try: diff --git a/src/crewai/llm.py b/src/crewai/llm.py index fb8367dfe..68ddbacc7 100644 --- a/src/crewai/llm.py +++ b/src/crewai/llm.py @@ -114,6 +114,60 @@ LLM_CONTEXT_WINDOW_SIZES = { "Llama-3.2-11B-Vision-Instruct": 16384, "Meta-Llama-3.2-3B-Instruct": 4096, "Meta-Llama-3.2-1B-Instruct": 16384, + # bedrock + "us.amazon.nova-pro-v1:0": 300000, + "us.amazon.nova-micro-v1:0": 128000, + "us.amazon.nova-lite-v1:0": 300000, + "us.anthropic.claude-3-5-sonnet-20240620-v1:0": 200000, + "us.anthropic.claude-3-5-haiku-20241022-v1:0": 200000, + "us.anthropic.claude-3-5-sonnet-20241022-v2:0": 200000, + "us.anthropic.claude-3-7-sonnet-20250219-v1:0": 200000, + "us.anthropic.claude-3-sonnet-20240229-v1:0": 200000, + "us.anthropic.claude-3-opus-20240229-v1:0": 200000, + "us.anthropic.claude-3-haiku-20240307-v1:0": 200000, + "us.meta.llama3-2-11b-instruct-v1:0": 128000, + "us.meta.llama3-2-3b-instruct-v1:0": 131000, + "us.meta.llama3-2-90b-instruct-v1:0": 128000, + "us.meta.llama3-2-1b-instruct-v1:0": 131000, + "us.meta.llama3-1-8b-instruct-v1:0": 128000, + "us.meta.llama3-1-70b-instruct-v1:0": 128000, + "us.meta.llama3-3-70b-instruct-v1:0": 128000, + "us.meta.llama3-1-405b-instruct-v1:0": 128000, + "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": 200000, + "eu.anthropic.claude-3-sonnet-20240229-v1:0": 200000, + "eu.anthropic.claude-3-haiku-20240307-v1:0": 200000, + "eu.meta.llama3-2-3b-instruct-v1:0": 131000, + "eu.meta.llama3-2-1b-instruct-v1:0": 131000, + "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": 200000, + "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": 200000, + "apac.anthropic.claude-3-sonnet-20240229-v1:0": 200000, + "apac.anthropic.claude-3-haiku-20240307-v1:0": 200000, + "amazon.nova-pro-v1:0": 300000, + "amazon.nova-micro-v1:0": 128000, + "amazon.nova-lite-v1:0": 300000, + "anthropic.claude-3-5-sonnet-20240620-v1:0": 200000, + "anthropic.claude-3-5-haiku-20241022-v1:0": 200000, + "anthropic.claude-3-5-sonnet-20241022-v2:0": 200000, + "anthropic.claude-3-7-sonnet-20250219-v1:0": 200000, + "anthropic.claude-3-sonnet-20240229-v1:0": 200000, + "anthropic.claude-3-opus-20240229-v1:0": 200000, + "anthropic.claude-3-haiku-20240307-v1:0": 200000, + "anthropic.claude-v2:1": 200000, + "anthropic.claude-v2": 100000, + "anthropic.claude-instant-v1": 100000, + "meta.llama3-1-405b-instruct-v1:0": 128000, + "meta.llama3-1-70b-instruct-v1:0": 128000, + "meta.llama3-1-8b-instruct-v1:0": 128000, + "meta.llama3-70b-instruct-v1:0": 8000, + "meta.llama3-8b-instruct-v1:0": 8000, + "amazon.titan-text-lite-v1": 4000, + "amazon.titan-text-express-v1": 8000, + "cohere.command-text-v14": 4000, + "ai21.j2-mid-v1": 8191, + "ai21.j2-ultra-v1": 8191, + "ai21.jamba-instruct-v1:0": 256000, + "mistral.mistral-7b-instruct-v0:2": 32000, + "mistral.mixtral-8x7b-instruct-v0:1": 32000, # mistral "mistral-tiny": 32768, "mistral-small-latest": 32768, diff --git a/src/crewai/memory/storage/mem0_storage.py b/src/crewai/memory/storage/mem0_storage.py index be889afff..0319c6a8a 100644 --- a/src/crewai/memory/storage/mem0_storage.py +++ b/src/crewai/memory/storage/mem0_storage.py @@ -1,7 +1,7 @@ import os from typing import Any, Dict, List -from mem0 import MemoryClient +from mem0 import Memory, MemoryClient from crewai.memory.storage.interface import Storage @@ -32,13 +32,16 @@ class Mem0Storage(Storage): mem0_org_id = config.get("org_id") mem0_project_id = config.get("project_id") - # Initialize MemoryClient with available parameters - if mem0_org_id and mem0_project_id: - self.memory = MemoryClient( - api_key=mem0_api_key, org_id=mem0_org_id, project_id=mem0_project_id - ) + # Initialize MemoryClient or Memory based on the presence of the mem0_api_key + if mem0_api_key: + if mem0_org_id and mem0_project_id: + self.memory = MemoryClient( + api_key=mem0_api_key, org_id=mem0_org_id, project_id=mem0_project_id + ) + else: + self.memory = MemoryClient(api_key=mem0_api_key) else: - self.memory = MemoryClient(api_key=mem0_api_key) + self.memory = Memory() # Fallback to Memory if no Mem0 API key is provided def _sanitize_role(self, role: str) -> str: """ diff --git a/src/crewai/task.py b/src/crewai/task.py index 0c063e4f9..10358147c 100644 --- a/src/crewai/task.py +++ b/src/crewai/task.py @@ -2,6 +2,7 @@ import datetime import inspect import json import logging +import re import threading import uuid from concurrent.futures import Future @@ -49,6 +50,7 @@ from crewai.utilities.events import ( from crewai.utilities.events.crewai_event_bus import crewai_event_bus from crewai.utilities.i18n import I18N from crewai.utilities.printer import Printer +from crewai.utilities.string_utils import interpolate_only class Task(BaseModel): @@ -507,7 +509,9 @@ class Task(BaseModel): return try: - self.description = self._original_description.format(**inputs) + self.description = interpolate_only( + input_string=self._original_description, inputs=inputs + ) except KeyError as e: raise ValueError( f"Missing required template variable '{e.args[0]}' in description" @@ -516,7 +520,7 @@ class Task(BaseModel): raise ValueError(f"Error interpolating description: {str(e)}") from e try: - self.expected_output = self.interpolate_only( + self.expected_output = interpolate_only( input_string=self._original_expected_output, inputs=inputs ) except (KeyError, ValueError) as e: @@ -524,7 +528,7 @@ class Task(BaseModel): if self.output_file is not None: try: - self.output_file = self.interpolate_only( + self.output_file = interpolate_only( input_string=self._original_output_file, inputs=inputs ) except (KeyError, ValueError) as e: @@ -555,72 +559,6 @@ class Task(BaseModel): f"\n\n{conversation_instruction}\n\n{conversation_history}" ) - def interpolate_only( - self, - input_string: Optional[str], - inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]], - ) -> str: - """Interpolate placeholders (e.g., {key}) in a string while leaving JSON untouched. - - Args: - input_string: The string containing template variables to interpolate. - Can be None or empty, in which case an empty string is returned. - inputs: Dictionary mapping template variables to their values. - Supported value types are strings, integers, floats, and dicts/lists - containing only these types and other nested dicts/lists. - - Returns: - The interpolated string with all template variables replaced with their values. - Empty string if input_string is None or empty. - - Raises: - ValueError: If a value contains unsupported types - """ - - # Validation function for recursive type checking - def validate_type(value: Any) -> None: - if value is None: - return - if isinstance(value, (str, int, float, bool)): - return - if isinstance(value, (dict, list)): - for item in value.values() if isinstance(value, dict) else value: - validate_type(item) - return - raise ValueError( - f"Unsupported type {type(value).__name__} in inputs. " - "Only str, int, float, bool, dict, and list are allowed." - ) - - # Validate all input values - for key, value in inputs.items(): - try: - validate_type(value) - except ValueError as e: - raise ValueError(f"Invalid value for key '{key}': {str(e)}") from e - - if input_string is None or not input_string: - return "" - if "{" not in input_string and "}" not in input_string: - return input_string - if not inputs: - raise ValueError( - "Inputs dictionary cannot be empty when interpolating variables" - ) - try: - escaped_string = input_string.replace("{", "{{").replace("}", "}}") - - for key in inputs.keys(): - escaped_string = escaped_string.replace(f"{{{{{key}}}}}", f"{{{key}}}") - - return escaped_string.format(**inputs) - except KeyError as e: - raise KeyError( - f"Template variable '{e.args[0]}' not found in inputs dictionary" - ) from e - except ValueError as e: - raise ValueError(f"Error during string interpolation: {str(e)}") from e - def increment_tools_errors(self) -> None: """Increment the tools errors counter.""" self.tools_errors += 1 diff --git a/src/crewai/telemetry/telemetry.py b/src/crewai/telemetry/telemetry.py index 984a4938d..559ca8d4f 100644 --- a/src/crewai/telemetry/telemetry.py +++ b/src/crewai/telemetry/telemetry.py @@ -281,8 +281,16 @@ class Telemetry: return self._safe_telemetry_operation(operation) def task_ended(self, span: Span, task: Task, crew: Crew): - """Records task execution in a crew.""" + """Records the completion of a task execution in a crew. + Args: + span (Span): The OpenTelemetry span tracking the task execution + task (Task): The task that was completed + crew (Crew): The crew context in which the task was executed + + Note: + If share_crew is enabled, this will also record the task output + """ def operation(): if crew.share_crew: self._add_attribute( @@ -297,8 +305,13 @@ class Telemetry: self._safe_telemetry_operation(operation) def tool_repeated_usage(self, llm: Any, tool_name: str, attempts: int): - """Records the repeated usage 'error' of a tool by an agent.""" + """Records when a tool is used repeatedly, which might indicate an issue. + Args: + llm (Any): The language model being used + tool_name (str): Name of the tool being repeatedly used + attempts (int): Number of attempts made with this tool + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Tool Repeated Usage") @@ -317,8 +330,13 @@ class Telemetry: self._safe_telemetry_operation(operation) def tool_usage(self, llm: Any, tool_name: str, attempts: int): - """Records the usage of a tool by an agent.""" + """Records the usage of a tool by an agent. + Args: + llm (Any): The language model being used + tool_name (str): Name of the tool being used + attempts (int): Number of attempts made with this tool + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Tool Usage") @@ -337,8 +355,11 @@ class Telemetry: self._safe_telemetry_operation(operation) def tool_usage_error(self, llm: Any): - """Records the usage of a tool by an agent.""" + """Records when a tool usage results in an error. + Args: + llm (Any): The language model being used when the error occurred + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Tool Usage Error") @@ -357,6 +378,14 @@ class Telemetry: def individual_test_result_span( self, crew: Crew, quality: float, exec_time: int, model_name: str ): + """Records individual test results for a crew execution. + + Args: + crew (Crew): The crew being tested + quality (float): Quality score of the execution + exec_time (int): Execution time in seconds + model_name (str): Name of the model used + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Crew Individual Test Result") @@ -383,6 +412,14 @@ class Telemetry: inputs: dict[str, Any] | None, model_name: str, ): + """Records the execution of a test suite for a crew. + + Args: + crew (Crew): The crew being tested + iterations (int): Number of test iterations + inputs (dict[str, Any] | None): Input parameters for the test + model_name (str): Name of the model used in testing + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Crew Test Execution") @@ -408,6 +445,7 @@ class Telemetry: self._safe_telemetry_operation(operation) def deploy_signup_error_span(self): + """Records when an error occurs during the deployment signup process.""" def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Deploy Signup Error") @@ -417,6 +455,11 @@ class Telemetry: self._safe_telemetry_operation(operation) def start_deployment_span(self, uuid: Optional[str] = None): + """Records the start of a deployment process. + + Args: + uuid (Optional[str]): Unique identifier for the deployment + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Start Deployment") @@ -428,6 +471,7 @@ class Telemetry: self._safe_telemetry_operation(operation) def create_crew_deployment_span(self): + """Records the creation of a new crew deployment.""" def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Create Crew Deployment") @@ -437,6 +481,12 @@ class Telemetry: self._safe_telemetry_operation(operation) def get_crew_logs_span(self, uuid: Optional[str], log_type: str = "deployment"): + """Records the retrieval of crew logs. + + Args: + uuid (Optional[str]): Unique identifier for the crew + log_type (str, optional): Type of logs being retrieved. Defaults to "deployment". + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Get Crew Logs") @@ -449,6 +499,11 @@ class Telemetry: self._safe_telemetry_operation(operation) def remove_crew_span(self, uuid: Optional[str] = None): + """Records the removal of a crew. + + Args: + uuid (Optional[str]): Unique identifier for the crew being removed + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Remove Crew") @@ -574,6 +629,11 @@ class Telemetry: self._safe_telemetry_operation(operation) def flow_creation_span(self, flow_name: str): + """Records the creation of a new flow. + + Args: + flow_name (str): Name of the flow being created + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Flow Creation") @@ -584,6 +644,12 @@ class Telemetry: self._safe_telemetry_operation(operation) def flow_plotting_span(self, flow_name: str, node_names: list[str]): + """Records flow visualization/plotting activity. + + Args: + flow_name (str): Name of the flow being plotted + node_names (list[str]): List of node names in the flow + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Flow Plotting") @@ -595,6 +661,12 @@ class Telemetry: self._safe_telemetry_operation(operation) def flow_execution_span(self, flow_name: str, node_names: list[str]): + """Records the execution of a flow. + + Args: + flow_name (str): Name of the flow being executed + node_names (list[str]): List of nodes being executed in the flow + """ def operation(): tracer = trace.get_tracer("crewai.telemetry") span = tracer.start_span("Flow Execution") diff --git a/src/crewai/tools/tool_usage.py b/src/crewai/tools/tool_usage.py index 25e4b126a..9c924027d 100644 --- a/src/crewai/tools/tool_usage.py +++ b/src/crewai/tools/tool_usage.py @@ -455,7 +455,7 @@ class ToolUsage: # Attempt 4: Repair JSON try: - repaired_input = repair_json(tool_input) + repaired_input = repair_json(tool_input, skip_json_loads=True) self._printer.print( content=f"Repaired JSON: {repaired_input}", color="blue" ) diff --git a/src/crewai/utilities/formatter.py b/src/crewai/utilities/formatter.py index 34da6cc43..19b2a74f9 100644 --- a/src/crewai/utilities/formatter.py +++ b/src/crewai/utilities/formatter.py @@ -1,10 +1,12 @@ -from typing import List +import re +from typing import TYPE_CHECKING, List -from crewai.task import Task -from crewai.tasks.task_output import TaskOutput +if TYPE_CHECKING: + from crewai.task import Task + from crewai.tasks.task_output import TaskOutput -def aggregate_raw_outputs_from_task_outputs(task_outputs: List[TaskOutput]) -> str: +def aggregate_raw_outputs_from_task_outputs(task_outputs: List["TaskOutput"]) -> str: """Generate string context from the task outputs.""" dividers = "\n\n----------\n\n" @@ -13,7 +15,7 @@ def aggregate_raw_outputs_from_task_outputs(task_outputs: List[TaskOutput]) -> s return context -def aggregate_raw_outputs_from_tasks(tasks: List[Task]) -> str: +def aggregate_raw_outputs_from_tasks(tasks: List["Task"]) -> str: """Generate string context from the tasks.""" task_outputs = [task.output for task in tasks if task.output is not None] diff --git a/src/crewai/utilities/planning_handler.py b/src/crewai/utilities/planning_handler.py index 6ce74f236..1bd14a0c8 100644 --- a/src/crewai/utilities/planning_handler.py +++ b/src/crewai/utilities/planning_handler.py @@ -96,6 +96,10 @@ class CrewPlanner: tasks_summary = [] for idx, task in enumerate(self.tasks): knowledge_list = self._get_agent_knowledge(task) + agent_tools = ( + f"[{', '.join(str(tool) for tool in task.agent.tools)}]" if task.agent and task.agent.tools else '"agent has no tools"', + f',\n "agent_knowledge": "[\\"{knowledge_list[0]}\\"]"' if knowledge_list and str(knowledge_list) != "None" else "" + ) task_summary = f""" Task Number {idx + 1} - {task.description} "task_description": {task.description} @@ -103,10 +107,7 @@ class CrewPlanner: "agent": {task.agent.role if task.agent else "None"} "agent_goal": {task.agent.goal if task.agent else "None"} "task_tools": {task.tools} - "agent_tools": %s%s""" % ( - f"[{', '.join(str(tool) for tool in task.agent.tools)}]" if task.agent and task.agent.tools else '"agent has no tools"', - f',\n "agent_knowledge": "[\\"{knowledge_list[0]}\\"]"' if knowledge_list and str(knowledge_list) != "None" else "" - ) + "agent_tools": {"".join(agent_tools)}""" tasks_summary.append(task_summary) return " ".join(tasks_summary) diff --git a/src/crewai/utilities/string_utils.py b/src/crewai/utilities/string_utils.py new file mode 100644 index 000000000..9a1857781 --- /dev/null +++ b/src/crewai/utilities/string_utils.py @@ -0,0 +1,82 @@ +import re +from typing import Any, Dict, List, Optional, Union + + +def interpolate_only( + input_string: Optional[str], + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]], +) -> str: + """Interpolate placeholders (e.g., {key}) in a string while leaving JSON untouched. + Only interpolates placeholders that follow the pattern {variable_name} where + variable_name starts with a letter/underscore and contains only letters, numbers, and underscores. + + Args: + input_string: The string containing template variables to interpolate. + Can be None or empty, in which case an empty string is returned. + inputs: Dictionary mapping template variables to their values. + Supported value types are strings, integers, floats, and dicts/lists + containing only these types and other nested dicts/lists. + + Returns: + The interpolated string with all template variables replaced with their values. + Empty string if input_string is None or empty. + + Raises: + ValueError: If a value contains unsupported types or a template variable is missing + """ + + # Validation function for recursive type checking + def validate_type(value: Any) -> None: + if value is None: + return + if isinstance(value, (str, int, float, bool)): + return + if isinstance(value, (dict, list)): + for item in value.values() if isinstance(value, dict) else value: + validate_type(item) + return + raise ValueError( + f"Unsupported type {type(value).__name__} in inputs. " + "Only str, int, float, bool, dict, and list are allowed." + ) + + # Validate all input values + for key, value in inputs.items(): + try: + validate_type(value) + except ValueError as e: + raise ValueError(f"Invalid value for key '{key}': {str(e)}") from e + + if input_string is None or not input_string: + return "" + if "{" not in input_string and "}" not in input_string: + return input_string + if not inputs: + raise ValueError( + "Inputs dictionary cannot be empty when interpolating variables" + ) + + # The regex pattern to find valid variable placeholders + # Matches {variable_name} where variable_name starts with a letter/underscore + # and contains only letters, numbers, and underscores + pattern = r"\{([A-Za-z_][A-Za-z0-9_]*)\}" + + # Find all matching variables in the input string + variables = re.findall(pattern, input_string) + result = input_string + + # Check if all variables exist in inputs + missing_vars = [var for var in variables if var not in inputs] + if missing_vars: + raise KeyError( + f"Template variable '{missing_vars[0]}' not found in inputs dictionary" + ) + + # Replace each variable with its value + for var in variables: + if var in inputs: + placeholder = "{" + var + "}" + value = str(inputs[var]) + result = result.replace(placeholder, value) + + return result diff --git a/tests/task_test.py b/tests/task_test.py index ac25a14f8..67ce99910 100644 --- a/tests/task_test.py +++ b/tests/task_test.py @@ -15,6 +15,7 @@ from crewai import Agent, Crew, Process, Task from crewai.tasks.conditional_task import ConditionalTask from crewai.tasks.task_output import TaskOutput from crewai.utilities.converter import Converter +from crewai.utilities.string_utils import interpolate_only def test_task_tool_reflect_agent_tools(): @@ -822,7 +823,7 @@ def test_interpolate_only(): # Test JSON structure preservation json_string = '{"info": "Look at {placeholder}", "nested": {"val": "{nestedVal}"}}' - result = task.interpolate_only( + result = interpolate_only( input_string=json_string, inputs={"placeholder": "the data", "nestedVal": "something else"}, ) @@ -833,20 +834,18 @@ def test_interpolate_only(): # Test normal string interpolation normal_string = "Hello {name}, welcome to {place}!" - result = task.interpolate_only( + result = interpolate_only( input_string=normal_string, inputs={"name": "John", "place": "CrewAI"} ) assert result == "Hello John, welcome to CrewAI!" # Test empty string - result = task.interpolate_only(input_string="", inputs={"unused": "value"}) + result = interpolate_only(input_string="", inputs={"unused": "value"}) assert result == "" # Test string with no placeholders no_placeholders = "Hello, this is a test" - result = task.interpolate_only( - input_string=no_placeholders, inputs={"unused": "value"} - ) + result = interpolate_only(input_string=no_placeholders, inputs={"unused": "value"}) assert result == no_placeholders @@ -858,7 +857,7 @@ def test_interpolate_only_with_dict_inside_expected_output(): ) json_string = '{"questions": {"main_question": "What is the user\'s name?", "secondary_question": "What is the user\'s age?"}}' - result = task.interpolate_only( + result = interpolate_only( input_string=json_string, inputs={ "questions": { @@ -872,18 +871,16 @@ def test_interpolate_only_with_dict_inside_expected_output(): assert result == json_string normal_string = "Hello {name}, welcome to {place}!" - result = task.interpolate_only( + result = interpolate_only( input_string=normal_string, inputs={"name": "John", "place": "CrewAI"} ) assert result == "Hello John, welcome to CrewAI!" - result = task.interpolate_only(input_string="", inputs={"unused": "value"}) + result = interpolate_only(input_string="", inputs={"unused": "value"}) assert result == "" no_placeholders = "Hello, this is a test" - result = task.interpolate_only( - input_string=no_placeholders, inputs={"unused": "value"} - ) + result = interpolate_only(input_string=no_placeholders, inputs={"unused": "value"}) assert result == no_placeholders @@ -1085,12 +1082,12 @@ def test_interpolate_with_list_of_strings(): # Test simple list of strings input_str = "Available items: {items}" inputs = {"items": ["apple", "banana", "cherry"]} - result = task.interpolate_only(input_str, inputs) + result = interpolate_only(input_str, inputs) assert result == f"Available items: {inputs['items']}" # Test empty list empty_list_input = {"items": []} - result = task.interpolate_only(input_str, empty_list_input) + result = interpolate_only(input_str, empty_list_input) assert result == "Available items: []" @@ -1106,7 +1103,7 @@ def test_interpolate_with_list_of_dicts(): {"name": "Bob", "age": 25, "skills": ["Java", "Cloud"]}, ] } - result = task.interpolate_only("{people}", input_data) + result = interpolate_only("{people}", input_data) parsed_result = eval(result) assert isinstance(parsed_result, list) @@ -1138,7 +1135,7 @@ def test_interpolate_with_nested_structures(): ], } } - result = task.interpolate_only("{company}", input_data) + result = interpolate_only("{company}", input_data) parsed = eval(result) assert parsed["name"] == "TechCorp" @@ -1161,7 +1158,7 @@ def test_interpolate_with_special_characters(): "empty": "", } } - result = task.interpolate_only("{special_data}", input_data) + result = interpolate_only("{special_data}", input_data) parsed = eval(result) assert parsed["quotes"] == """This has "double" and 'single' quotes""" @@ -1188,7 +1185,7 @@ def test_interpolate_mixed_types(): }, } } - result = task.interpolate_only("{data}", input_data) + result = interpolate_only("{data}", input_data) parsed = eval(result) assert parsed["name"] == "Test Dataset" @@ -1216,7 +1213,7 @@ def test_interpolate_complex_combination(): }, ] } - result = task.interpolate_only("{report}", input_data) + result = interpolate_only("{report}", input_data) parsed = eval(result) assert len(parsed) == 2 @@ -1233,7 +1230,7 @@ def test_interpolate_invalid_type_validation(): # Test with invalid top-level type with pytest.raises(ValueError) as excinfo: - task.interpolate_only("{data}", {"data": set()}) # type: ignore we are purposely testing this failure + interpolate_only("{data}", {"data": set()}) # type: ignore we are purposely testing this failure assert "Unsupported type set" in str(excinfo.value) @@ -1246,7 +1243,7 @@ def test_interpolate_invalid_type_validation(): } } with pytest.raises(ValueError) as excinfo: - task.interpolate_only("{data}", {"data": invalid_nested}) + interpolate_only("{data}", {"data": invalid_nested}) assert "Unsupported type set" in str(excinfo.value) @@ -1265,24 +1262,22 @@ def test_interpolate_custom_object_validation(): # Test with custom object at top level with pytest.raises(ValueError) as excinfo: - task.interpolate_only("{obj}", {"obj": CustomObject(5)}) # type: ignore we are purposely testing this failure + interpolate_only("{obj}", {"obj": CustomObject(5)}) # type: ignore we are purposely testing this failure assert "Unsupported type CustomObject" in str(excinfo.value) # Test with nested custom object in dictionary with pytest.raises(ValueError) as excinfo: - task.interpolate_only( - "{data}", {"data": {"valid": 1, "invalid": CustomObject(5)}} - ) + interpolate_only("{data}", {"data": {"valid": 1, "invalid": CustomObject(5)}}) assert "Unsupported type CustomObject" in str(excinfo.value) # Test with nested custom object in list with pytest.raises(ValueError) as excinfo: - task.interpolate_only("{data}", {"data": [1, "valid", CustomObject(5)]}) + interpolate_only("{data}", {"data": [1, "valid", CustomObject(5)]}) assert "Unsupported type CustomObject" in str(excinfo.value) # Test with deeply nested custom object with pytest.raises(ValueError) as excinfo: - task.interpolate_only( + interpolate_only( "{data}", {"data": {"level1": {"level2": [{"level3": CustomObject(5)}]}}} ) assert "Unsupported type CustomObject" in str(excinfo.value) @@ -1306,7 +1301,7 @@ def test_interpolate_valid_complex_types(): } # Should not raise any errors - result = task.interpolate_only("{data}", {"data": valid_data}) + result = interpolate_only("{data}", {"data": valid_data}) parsed = eval(result) assert parsed["name"] == "Valid Dataset" assert parsed["stats"]["nested"]["deeper"]["b"] == 2.5 @@ -1319,16 +1314,16 @@ def test_interpolate_edge_cases(): ) # Test empty dict and list - assert task.interpolate_only("{}", {"data": {}}) == "{}" - assert task.interpolate_only("[]", {"data": []}) == "[]" + assert interpolate_only("{}", {"data": {}}) == "{}" + assert interpolate_only("[]", {"data": []}) == "[]" # Test numeric types - assert task.interpolate_only("{num}", {"num": 42}) == "42" - assert task.interpolate_only("{num}", {"num": 3.14}) == "3.14" + assert interpolate_only("{num}", {"num": 42}) == "42" + assert interpolate_only("{num}", {"num": 3.14}) == "3.14" # Test boolean values (valid JSON types) - assert task.interpolate_only("{flag}", {"flag": True}) == "True" - assert task.interpolate_only("{flag}", {"flag": False}) == "False" + assert interpolate_only("{flag}", {"flag": True}) == "True" + assert interpolate_only("{flag}", {"flag": False}) == "False" def test_interpolate_valid_types(): @@ -1346,7 +1341,7 @@ def test_interpolate_valid_types(): "nested": {"flag": True, "empty": None}, } - result = task.interpolate_only("{data}", {"data": valid_data}) + result = interpolate_only("{data}", {"data": valid_data}) parsed = eval(result) assert parsed["active"] is True diff --git a/tests/utilities/test_string_utils.py b/tests/utilities/test_string_utils.py new file mode 100644 index 000000000..441aae8c0 --- /dev/null +++ b/tests/utilities/test_string_utils.py @@ -0,0 +1,187 @@ +from typing import Any, Dict, List, Union + +import pytest + +from crewai.utilities.string_utils import interpolate_only + + +class TestInterpolateOnly: + """Tests for the interpolate_only function in string_utils.py.""" + + def test_basic_variable_interpolation(self): + """Test basic variable interpolation works correctly.""" + template = "Hello, {name}! Welcome to {company}." + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "name": "Alice", + "company": "CrewAI", + } + + result = interpolate_only(template, inputs) + + assert result == "Hello, Alice! Welcome to CrewAI." + + def test_multiple_occurrences_of_same_variable(self): + """Test that multiple occurrences of the same variable are replaced.""" + template = "{name} is using {name}'s account." + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "name": "Bob" + } + + result = interpolate_only(template, inputs) + + assert result == "Bob is using Bob's account." + + def test_json_structure_preservation(self): + """Test that JSON structures are preserved and not interpolated incorrectly.""" + template = """ + Instructions for {agent}: + + Please return the following object: + + {"name": "person's name", "age": 25, "skills": ["coding", "testing"]} + """ + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "agent": "DevAgent" + } + + result = interpolate_only(template, inputs) + + assert "Instructions for DevAgent:" in result + assert ( + '{"name": "person\'s name", "age": 25, "skills": ["coding", "testing"]}' + in result + ) + + def test_complex_nested_json(self): + """Test with complex JSON structures containing curly braces.""" + template = """ + {agent} needs to process: + { + "config": { + "nested": { + "value": 42 + }, + "arrays": [1, 2, {"inner": "value"}] + } + } + """ + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "agent": "DataProcessor" + } + + result = interpolate_only(template, inputs) + + assert "DataProcessor needs to process:" in result + assert '"nested": {' in result + assert '"value": 42' in result + assert '[1, 2, {"inner": "value"}]' in result + + def test_missing_variable(self): + """Test that an error is raised when a required variable is missing.""" + template = "Hello, {name}!" + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "not_name": "Alice" + } + + with pytest.raises(KeyError) as excinfo: + interpolate_only(template, inputs) + + assert "template variable" in str(excinfo.value).lower() + assert "name" in str(excinfo.value) + + def test_invalid_input_types(self): + """Test that an error is raised with invalid input types.""" + template = "Hello, {name}!" + # Using Any for this test since we're intentionally testing an invalid type + inputs: Dict[str, Any] = {"name": object()} # Object is not a valid input type + + with pytest.raises(ValueError) as excinfo: + interpolate_only(template, inputs) + + assert "unsupported type" in str(excinfo.value).lower() + + def test_empty_input_string(self): + """Test handling of empty or None input string.""" + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "name": "Alice" + } + + assert interpolate_only("", inputs) == "" + assert interpolate_only(None, inputs) == "" + + def test_no_variables_in_template(self): + """Test a template with no variables to replace.""" + template = "This is a static string with no variables." + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "name": "Alice" + } + + result = interpolate_only(template, inputs) + + assert result == template + + def test_variable_name_starting_with_underscore(self): + """Test variables starting with underscore are replaced correctly.""" + template = "Variable: {_special_var}" + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "_special_var": "Special Value" + } + + result = interpolate_only(template, inputs) + + assert result == "Variable: Special Value" + + def test_preserves_non_matching_braces(self): + """Test that non-matching braces patterns are preserved.""" + template = ( + "This {123} and {!var} should not be replaced but {valid_var} should." + ) + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "valid_var": "works" + } + + result = interpolate_only(template, inputs) + + assert ( + result == "This {123} and {!var} should not be replaced but works should." + ) + + def test_complex_mixed_scenario(self): + """Test a complex scenario with both valid variables and JSON structures.""" + template = """ + {agent_name} is working on task {task_id}. + + Instructions: + 1. Process the data + 2. Return results as: + + { + "taskId": "{task_id}", + "results": { + "processed_by": "agent_name", + "status": "complete", + "values": [1, 2, 3] + } + } + """ + inputs: Dict[str, Union[str, int, float, Dict[str, Any], List[Any]]] = { + "agent_name": "AnalyticsAgent", + "task_id": "T-12345", + } + + result = interpolate_only(template, inputs) + + assert "AnalyticsAgent is working on task T-12345" in result + assert '"taskId": "T-12345"' in result + assert '"processed_by": "agent_name"' in result # This shouldn't be replaced + assert '"values": [1, 2, 3]' in result + + def test_empty_inputs_dictionary(self): + """Test that an error is raised with empty inputs dictionary.""" + template = "Hello, {name}!" + inputs: Dict[str, Any] = {} + + with pytest.raises(ValueError) as excinfo: + interpolate_only(template, inputs) + + assert "inputs dictionary cannot be empty" in str(excinfo.value).lower()