From af5c13083668633586f118cc30a65e9527a78b4c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 13:32:07 +0000 Subject: [PATCH] Fix #5931: Propagate Crew.prompt_file to all components during execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, setting prompt_file on a Crew had no effect on agents, tasks, tools, or any other components. The custom I18N instance was only used for the manager agent's role/goal/backstory in hierarchical mode, while all other code used the hardcoded I18N_DEFAULT singleton. This fix introduces a contextvars-based mechanism that scopes a custom I18N instance to the crew's execution: - Added get_crew_i18n() / set_crew_i18n() / reset_crew_i18n() to i18n.py using a ContextVar for thread-safe crew-scoped I18N overrides - prepare_kickoff() now sets the crew's I18N when prompt_file is provided - Crew.kickoff() resets the I18N context in its finally block (even on errors) - Replaced all I18N_DEFAULT references with get_crew_i18n() calls across 29 source files so every component (Prompts, Task, agent tools, tool usage, memory, reasoning, etc.) respects the crew's prompt_file Added 12 new tests covering: - Context variable set/get/reset/nesting behavior - Prompts class using custom I18N - Task.prompt() using custom I18N - AgentTools delegation tools using custom I18N - Full Crew.kickoff() integration (sets context, resets on success/exception) Co-Authored-By: João --- lib/crewai/src/crewai/agent/core.py | 14 +- lib/crewai/src/crewai/agent/utils.py | 10 +- .../langgraph/langgraph_adapter.py | 4 +- .../openai_agents/openai_adapter.py | 4 +- .../structured_output_converter.py | 4 +- .../src/crewai/agents/crew_agent_executor.py | 10 +- lib/crewai/src/crewai/agents/parser.py | 6 +- .../src/crewai/agents/planner_observer.py | 6 +- lib/crewai/src/crewai/agents/step_executor.py | 18 +- lib/crewai/src/crewai/crew.py | 6 + lib/crewai/src/crewai/crews/utils.py | 5 + .../src/crewai/experimental/agent_executor.py | 14 +- lib/crewai/src/crewai/flow/flow.py | 4 +- lib/crewai/src/crewai/flow/human_feedback.py | 4 +- lib/crewai/src/crewai/lite_agent.py | 12 +- lib/crewai/src/crewai/memory/analyze.py | 4 +- lib/crewai/src/crewai/task.py | 10 +- .../tools/agent_tools/add_image_tool.py | 8 +- .../crewai/tools/agent_tools/agent_tools.py | 6 +- .../tools/agent_tools/base_agent_tools.py | 10 +- lib/crewai/src/crewai/tools/memory_tools.py | 6 +- lib/crewai/src/crewai/tools/tool_usage.py | 26 +- .../src/crewai/utilities/agent_utils.py | 16 +- lib/crewai/src/crewai/utilities/converter.py | 8 +- .../utilities/evaluators/task_evaluator.py | 6 +- lib/crewai/src/crewai/utilities/i18n.py | 31 ++ lib/crewai/src/crewai/utilities/prompts.py | 8 +- .../src/crewai/utilities/reasoning_handler.py | 18 +- lib/crewai/src/crewai/utilities/tool_utils.py | 6 +- .../tests/utilities/custom_prompts.json | 101 ++++++ .../utilities/test_prompt_file_propagation.py | 293 ++++++++++++++++++ 31 files changed, 557 insertions(+), 121 deletions(-) create mode 100644 lib/crewai/tests/utilities/custom_prompts.json create mode 100644 lib/crewai/tests/utilities/test_prompt_file_propagation.py diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 7310c53be..4d7d5b751 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -102,7 +102,7 @@ from crewai.utilities.converter import Converter, ConverterError from crewai.utilities.env import get_env_context from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_for_json from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.llm_utils import create_llm from crewai.utilities.prompts import Prompts, StandardPromptResult, SystemPromptResult from crewai.utilities.pydantic_schema_utils import generate_model_description @@ -608,7 +608,7 @@ class Agent(BaseAgent): m.format() for m in matches ) if memory.strip() != "": - task_prompt += I18N_DEFAULT.slice("memory").format(memory=memory) + task_prompt += get_crew_i18n().slice("memory").format(memory=memory) crewai_event_bus.emit( self, @@ -1024,7 +1024,7 @@ class Agent(BaseAgent): response_template=self.response_template, ).task_execution() - stop_words = [I18N_DEFAULT.slice("observation")] + stop_words = [get_crew_i18n().slice("observation")] if self.response_template: stop_words.append( self.response_template.split("{{ .Response }}")[1].strip() @@ -1310,10 +1310,10 @@ class Agent(BaseAgent): from_agent=self, ), ) - query = I18N_DEFAULT.slice("knowledge_search_query").format( + query = get_crew_i18n().slice("knowledge_search_query").format( task_prompt=task_prompt ) - rewriter_prompt = I18N_DEFAULT.slice("knowledge_search_query_system_prompt") + rewriter_prompt = get_crew_i18n().slice("knowledge_search_query_system_prompt") if not isinstance(self.llm, BaseLLM): self._logger.log( "warning", @@ -1488,7 +1488,7 @@ class Agent(BaseAgent): m.format() for m in matches ) if memory_block: - formatted_messages += "\n\n" + I18N_DEFAULT.slice("memory").format( + formatted_messages += "\n\n" + get_crew_i18n().slice("memory").format( memory=memory_block ) crewai_event_bus.emit( @@ -1703,7 +1703,7 @@ class Agent(BaseAgent): try: model_schema = generate_model_description(response_format) schema = json.dumps(model_schema, indent=2) - instructions = I18N_DEFAULT.slice("formatted_task_instructions").format( + instructions = get_crew_i18n().slice("formatted_task_instructions").format( output_format=schema ) diff --git a/lib/crewai/src/crewai/agent/utils.py b/lib/crewai/src/crewai/agent/utils.py index 47701c8e4..51630d48a 100644 --- a/lib/crewai/src/crewai/agent/utils.py +++ b/lib/crewai/src/crewai/agent/utils.py @@ -67,19 +67,19 @@ def build_task_prompt_with_schema(task: Task, task_prompt: str) -> str: Returns: The task prompt potentially augmented with schema instructions. """ - from crewai.utilities.i18n import I18N_DEFAULT + from crewai.utilities.i18n import get_crew_i18n if (task.output_json or task.output_pydantic) and not task.response_model: if task.output_json: schema_dict = generate_model_description(task.output_json) schema = json.dumps(schema_dict["json_schema"]["schema"], indent=2) - task_prompt += "\n" + I18N_DEFAULT.slice( + task_prompt += "\n" + get_crew_i18n().slice( "formatted_task_instructions" ).format(output_format=schema) elif task.output_pydantic: schema_dict = generate_model_description(task.output_pydantic) schema = json.dumps(schema_dict["json_schema"]["schema"], indent=2) - task_prompt += "\n" + I18N_DEFAULT.slice( + task_prompt += "\n" + get_crew_i18n().slice( "formatted_task_instructions" ).format(output_format=schema) return task_prompt @@ -95,10 +95,10 @@ def format_task_with_context(task_prompt: str, context: str | None) -> str: Returns: The task prompt formatted with context if provided. """ - from crewai.utilities.i18n import I18N_DEFAULT + from crewai.utilities.i18n import get_crew_i18n if context: - return I18N_DEFAULT.slice("task_with_context").format( + return get_crew_i18n().slice("task_with_context").format( task=task_prompt, context=context ) return task_prompt diff --git a/lib/crewai/src/crewai/agents/agent_adapters/langgraph/langgraph_adapter.py b/lib/crewai/src/crewai/agents/agent_adapters/langgraph/langgraph_adapter.py index 5c9a3f3d4..5d2707e32 100644 --- a/lib/crewai/src/crewai/agents/agent_adapters/langgraph/langgraph_adapter.py +++ b/lib/crewai/src/crewai/agents/agent_adapters/langgraph/langgraph_adapter.py @@ -33,7 +33,7 @@ from crewai.tools.base_tool import BaseTool from crewai.types.callback import SerializableCallable from crewai.utilities import Logger from crewai.utilities.converter import Converter -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.import_utils import require @@ -190,7 +190,7 @@ class LangGraphAgentAdapter(BaseAgentAdapter): task_prompt = task.prompt() if hasattr(task, "prompt") else str(task) if context: - task_prompt = I18N_DEFAULT.slice("task_with_context").format( + task_prompt = get_crew_i18n().slice("task_with_context").format( task=task_prompt, context=context ) diff --git a/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/openai_adapter.py b/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/openai_adapter.py index 139d4dabc..eab794316 100644 --- a/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/openai_adapter.py +++ b/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/openai_adapter.py @@ -32,7 +32,7 @@ from crewai.events.types.agent_events import ( from crewai.tools import BaseTool from crewai.tools.agent_tools.agent_tools import AgentTools from crewai.utilities import Logger -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.import_utils import require @@ -137,7 +137,7 @@ class OpenAIAgentAdapter(BaseAgentAdapter): try: task_prompt: str = task.prompt() if context: - task_prompt = I18N_DEFAULT.slice("task_with_context").format( + task_prompt = get_crew_i18n().slice("task_with_context").format( task=task_prompt, context=context ) crewai_event_bus.emit( diff --git a/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/structured_output_converter.py b/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/structured_output_converter.py index 358281cac..cd665e228 100644 --- a/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/structured_output_converter.py +++ b/lib/crewai/src/crewai/agents/agent_adapters/openai_agents/structured_output_converter.py @@ -8,7 +8,7 @@ import json from typing import Any from crewai.agents.agent_adapters.base_converter_adapter import BaseConverterAdapter -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n class OpenAIConverterAdapter(BaseConverterAdapter): @@ -59,7 +59,7 @@ class OpenAIConverterAdapter(BaseConverterAdapter): if not self._output_format: return base_prompt - output_schema: str = I18N_DEFAULT.slice("formatted_task_instructions").format( + output_schema: str = get_crew_i18n().slice("formatted_task_instructions").format( output_format=json.dumps(self._schema, indent=2) ) diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index fce80ad7a..cafdb9688 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -70,7 +70,7 @@ from crewai.utilities.agent_utils import ( ) from crewai.utilities.constants import TRAINING_DATA_FILE from crewai.utilities.file_store import aget_all_files, get_all_files -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.string_utils import sanitize_tool_name from crewai.utilities.token_counter_callback import TokenCalcHandler from crewai.utilities.tool_utils import ( @@ -751,7 +751,7 @@ class CrewAgentExecutor(BaseAgentExecutor): if tool_finish: return tool_finish - reasoning_prompt = I18N_DEFAULT.slice("post_tool_reasoning") + reasoning_prompt = get_crew_i18n().slice("post_tool_reasoning") reasoning_message: LLMMessage = { "role": "user", "content": reasoning_prompt, @@ -774,7 +774,7 @@ class CrewAgentExecutor(BaseAgentExecutor): if tool_finish: return tool_finish - reasoning_prompt = I18N_DEFAULT.slice("post_tool_reasoning") + reasoning_prompt = get_crew_i18n().slice("post_tool_reasoning") reasoning_message = { "role": "user", "content": reasoning_prompt, @@ -1430,7 +1430,7 @@ class CrewAgentExecutor(BaseAgentExecutor): Returns: Updated action or final answer. """ - add_image_tool = I18N_DEFAULT.tools("add_image") + add_image_tool = get_crew_i18n().tools("add_image") if ( isinstance(add_image_tool, dict) and formatted_answer.tool.casefold().strip() @@ -1632,5 +1632,5 @@ class CrewAgentExecutor(BaseAgentExecutor): Formatted message dict. """ return format_message_for_llm( - I18N_DEFAULT.slice("feedback_instructions").format(feedback=feedback) + get_crew_i18n().slice("feedback_instructions").format(feedback=feedback) ) diff --git a/lib/crewai/src/crewai/agents/parser.py b/lib/crewai/src/crewai/agents/parser.py index c59719226..c490c13d3 100644 --- a/lib/crewai/src/crewai/agents/parser.py +++ b/lib/crewai/src/crewai/agents/parser.py @@ -19,7 +19,7 @@ from crewai.agents.constants import ( MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE, UNABLE_TO_REPAIR_JSON_RESULTS, ) -from crewai.utilities.i18n import I18N_DEFAULT as _I18N +from crewai.utilities.i18n import get_crew_i18n @dataclass @@ -115,13 +115,13 @@ def parse(text: str) -> AgentAction | AgentFinish: if not ACTION_REGEX.search(text): raise OutputParserError( - f"{MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE}\n{_I18N.slice('final_answer_format')}", + f"{MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE}\n{get_crew_i18n().slice('final_answer_format')}", ) if not ACTION_INPUT_ONLY_REGEX.search(text): raise OutputParserError( MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE, ) - err_format = _I18N.slice("format_without_tools") + err_format = get_crew_i18n().slice("format_without_tools") error = f"{err_format}" raise OutputParserError( error, diff --git a/lib/crewai/src/crewai/agents/planner_observer.py b/lib/crewai/src/crewai/agents/planner_observer.py index 29d586663..989442bf6 100644 --- a/lib/crewai/src/crewai/agents/planner_observer.py +++ b/lib/crewai/src/crewai/agents/planner_observer.py @@ -23,7 +23,7 @@ from crewai.events.types.observation_events import ( StepObservationStartedEvent, ) from crewai.utilities.agent_utils import extract_task_section -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.llm_utils import create_llm from crewai.utilities.planning_types import StepObservation, TodoItem from crewai.utilities.types import LLMMessage @@ -231,7 +231,7 @@ class PlannerObserver: task_desc = extract_task_section(self.kickoff_input) task_goal = "Complete the task successfully" - system_prompt = I18N_DEFAULT.retrieve("planning", "observation_system_prompt") + system_prompt = get_crew_i18n().retrieve("planning", "observation_system_prompt") completed_summary = "" if all_completed: @@ -256,7 +256,7 @@ class PlannerObserver: remaining_lines ) - user_prompt = I18N_DEFAULT.retrieve( + user_prompt = get_crew_i18n().retrieve( "planning", "observation_user_prompt" ).format( task_description=task_desc, diff --git a/lib/crewai/src/crewai/agents/step_executor.py b/lib/crewai/src/crewai/agents/step_executor.py index 5fe517389..bec5f8521 100644 --- a/lib/crewai/src/crewai/agents/step_executor.py +++ b/lib/crewai/src/crewai/agents/step_executor.py @@ -39,7 +39,7 @@ from crewai.utilities.agent_utils import ( process_llm_response, setup_native_tools, ) -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.planning_types import TodoItem from crewai.utilities.step_execution_context import StepExecutionContext, StepResult from crewai.utilities.string_utils import sanitize_tool_name @@ -210,14 +210,14 @@ class StepExecutor: tools_section = "" if self.tools and not self._use_native_tools: tool_names = ", ".join(sanitize_tool_name(t.name) for t in self.tools) - tools_section = I18N_DEFAULT.retrieve( + tools_section = get_crew_i18n().retrieve( "planning", "step_executor_tools_section" ).format(tool_names=tool_names) elif self.tools: tool_names = ", ".join(sanitize_tool_name(t.name) for t in self.tools) tools_section = f"\n\nAvailable tools: {tool_names}" - return I18N_DEFAULT.retrieve("planning", "step_executor_system_prompt").format( + return get_crew_i18n().retrieve("planning", "step_executor_system_prompt").format( role=role, backstory=backstory, goal=goal, @@ -232,7 +232,7 @@ class StepExecutor: task_section = extract_task_section(context.task_description) if task_section: parts.append( - I18N_DEFAULT.retrieve( + get_crew_i18n().retrieve( "planning", "step_executor_task_context" ).format( task_context=task_section, @@ -240,14 +240,14 @@ class StepExecutor: ) parts.append( - I18N_DEFAULT.retrieve("planning", "step_executor_user_prompt").format( + get_crew_i18n().retrieve("planning", "step_executor_user_prompt").format( step_description=todo.description, ) ) if todo.tool_to_use: parts.append( - I18N_DEFAULT.retrieve( + get_crew_i18n().retrieve( "planning", "step_executor_suggested_tool" ).format( tool_to_use=todo.tool_to_use, @@ -256,16 +256,16 @@ class StepExecutor: if context.dependency_results: parts.append( - I18N_DEFAULT.retrieve("planning", "step_executor_context_header") + get_crew_i18n().retrieve("planning", "step_executor_context_header") ) for step_num, result in sorted(context.dependency_results.items()): parts.append( - I18N_DEFAULT.retrieve( + get_crew_i18n().retrieve( "planning", "step_executor_context_entry" ).format(step_number=step_num, result=result) ) - parts.append(I18N_DEFAULT.retrieve("planning", "step_executor_complete_step")) + parts.append(get_crew_i18n().retrieve("planning", "step_executor_complete_step")) return "\n".join(parts) diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index 870049179..85826da65 100644 --- a/lib/crewai/src/crewai/crew.py +++ b/lib/crewai/src/crewai/crew.py @@ -213,6 +213,7 @@ class Crew(FlowTrackable, BaseModel): default_factory=TaskOutputStorageHandler ) _kickoff_event_id: str | None = PrivateAttr(default=None) + _i18n_token: Any = PrivateAttr(default=None) name: str | None = Field(default="crew") cache: bool = Field(default=True) @@ -1044,6 +1045,11 @@ class Crew(FlowTrackable, BaseModel): if self._memory is not None and hasattr(self._memory, "drain_writes"): self._memory.drain_writes() clear_files(self.id) + if self._i18n_token is not None: + from crewai.utilities.i18n import reset_crew_i18n + + reset_crew_i18n(self._i18n_token) + self._i18n_token = None detach(token) def _post_kickoff(self, result: CrewOutput) -> CrewOutput: diff --git a/lib/crewai/src/crewai/crews/utils.py b/lib/crewai/src/crewai/crews/utils.py index 70d624f6f..f1fbb332a 100644 --- a/lib/crewai/src/crewai/crews/utils.py +++ b/lib/crewai/src/crewai/crews/utils.py @@ -354,6 +354,11 @@ def prepare_kickoff( crew._set_tasks_callbacks() crew._set_allow_crewai_trigger_context_for_first_task() + if crew.prompt_file: + from crewai.utilities.i18n import get_i18n, set_crew_i18n + + crew._i18n_token = set_crew_i18n(get_i18n(crew.prompt_file)) + agents_to_setup: list[BaseAgent] = list(crew.agents) seen_agent_ids: set[int] = {id(agent) for agent in agents_to_setup} for task in crew.tasks: diff --git a/lib/crewai/src/crewai/experimental/agent_executor.py b/lib/crewai/src/crewai/experimental/agent_executor.py index 0f31b8eb2..fe8bf22e2 100644 --- a/lib/crewai/src/crewai/experimental/agent_executor.py +++ b/lib/crewai/src/crewai/experimental/agent_executor.py @@ -93,7 +93,7 @@ from crewai.utilities.agent_utils import ( track_delegation_if_needed, ) from crewai.utilities.constants import TRAINING_DATA_FILE -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.planning_types import ( PlanStep, StepObservation, @@ -1448,7 +1448,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): action.result = str(e) self._append_message_to_state(action.text) - reasoning_prompt = I18N_DEFAULT.slice("post_tool_reasoning") + reasoning_prompt = get_crew_i18n().slice("post_tool_reasoning") reasoning_message: LLMMessage = { "role": "user", "content": reasoning_prompt, @@ -1469,7 +1469,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): self.state.is_finished = True return "tool_result_is_final" - reasoning_prompt = I18N_DEFAULT.slice("post_tool_reasoning") + reasoning_prompt = get_crew_i18n().slice("post_tool_reasoning") reasoning_message_post: LLMMessage = { "role": "user", "content": reasoning_prompt, @@ -2220,10 +2220,10 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): # Build synthesis prompt role = self.agent.role if self.agent else "Assistant" - system_prompt = I18N_DEFAULT.retrieve( + system_prompt = get_crew_i18n().retrieve( "planning", "synthesis_system_prompt" ).format(role=role) - user_prompt = I18N_DEFAULT.retrieve("planning", "synthesis_user_prompt").format( + user_prompt = get_crew_i18n().retrieve("planning", "synthesis_user_prompt").format( task_description=task_description, combined_steps=combined_steps, ) @@ -2470,7 +2470,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): self.task.description if self.task else getattr(self, "_kickoff_input", "") ) - enhancement = I18N_DEFAULT.retrieve( + enhancement = get_crew_i18n().retrieve( "planning", "replan_enhancement_prompt" ).format(previous_context=previous_context) @@ -2776,7 +2776,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): Returns: Updated action or final answer. """ - add_image_tool = I18N_DEFAULT.tools("add_image") + add_image_tool = get_crew_i18n().tools("add_image") if ( isinstance(add_image_tool, dict) and formatted_answer.tool.casefold().strip() diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index ef9658128..592ac05dc 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -3511,7 +3511,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): from crewai.llm import LLM from crewai.llms.base_llm import BaseLLM as BaseLLMClass - from crewai.utilities.i18n import I18N_DEFAULT + from crewai.utilities.i18n import get_crew_i18n llm_instance: BaseLLMClass if isinstance(llm, str): @@ -3531,7 +3531,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): description=f"The outcome that best matches the feedback. Must be one of: {', '.join(outcomes)}" ) - prompt_template = I18N_DEFAULT.slice("human_feedback_collapse") + prompt_template = get_crew_i18n().slice("human_feedback_collapse") prompt = prompt_template.format( feedback=feedback, diff --git a/lib/crewai/src/crewai/flow/human_feedback.py b/lib/crewai/src/crewai/flow/human_feedback.py index 5278d0073..d3487e422 100644 --- a/lib/crewai/src/crewai/flow/human_feedback.py +++ b/lib/crewai/src/crewai/flow/human_feedback.py @@ -363,9 +363,9 @@ def human_feedback( def _get_hitl_prompt(key: str) -> str: """Read a HITL prompt from the i18n translations.""" - from crewai.utilities.i18n import I18N_DEFAULT + from crewai.utilities.i18n import get_crew_i18n - return I18N_DEFAULT.slice(key) + return get_crew_i18n().slice(key) def _resolve_llm_instance() -> Any: """Resolve the ``llm`` parameter to a BaseLLM instance. diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index 3aff8ea35..7bb61508d 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -93,7 +93,7 @@ from crewai.utilities.converter import ( ) from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_for_json from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.llm_utils import create_llm from crewai.utilities.pydantic_schema_utils import ( generate_model_description, @@ -577,7 +577,7 @@ class LiteAgent(FlowTrackable, BaseModel): f"- {m.record.content}" for m in matches ) if memory_block: - formatted = I18N_DEFAULT.slice("memory").format(memory=memory_block) + formatted = get_crew_i18n().slice("memory").format(memory=memory_block) if self._messages and self._messages[0].get("role") == "system": existing_content = self._messages[0].get("content", "") if not isinstance(existing_content, str): @@ -650,7 +650,7 @@ class LiteAgent(FlowTrackable, BaseModel): try: model_schema = generate_model_description(active_response_format) schema = json.dumps(model_schema, indent=2) - instructions = I18N_DEFAULT.slice("formatted_task_instructions").format( + instructions = get_crew_i18n().slice("formatted_task_instructions").format( output_format=schema ) @@ -799,7 +799,7 @@ class LiteAgent(FlowTrackable, BaseModel): base_prompt = "" if self._parsed_tools: # Use the prompt template for agents with tools - base_prompt = I18N_DEFAULT.slice( + base_prompt = get_crew_i18n().slice( "lite_agent_system_prompt_with_tools" ).format( role=self.role, @@ -810,7 +810,7 @@ class LiteAgent(FlowTrackable, BaseModel): ) else: # Use the prompt template for agents without tools - base_prompt = I18N_DEFAULT.slice( + base_prompt = get_crew_i18n().slice( "lite_agent_system_prompt_without_tools" ).format( role=self.role, @@ -822,7 +822,7 @@ class LiteAgent(FlowTrackable, BaseModel): if active_response_format: model_description = generate_model_description(active_response_format) schema_json = json.dumps(model_description, indent=2) - base_prompt += I18N_DEFAULT.slice("lite_agent_response_format").format( + base_prompt += get_crew_i18n().slice("lite_agent_response_format").format( response_format=schema_json ) diff --git a/lib/crewai/src/crewai/memory/analyze.py b/lib/crewai/src/crewai/memory/analyze.py index 65d671d0d..14d4fd910 100644 --- a/lib/crewai/src/crewai/memory/analyze.py +++ b/lib/crewai/src/crewai/memory/analyze.py @@ -9,7 +9,7 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field from crewai.memory.types import MemoryRecord, ScopeInfo -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n _logger = logging.getLogger(__name__) @@ -149,7 +149,7 @@ def _get_prompt(key: str) -> str: Returns: The prompt string. """ - return I18N_DEFAULT.memory(key) + return get_crew_i18n().memory(key) def extract_memories_from_content(content: str, llm: Any) -> list[str]: diff --git a/lib/crewai/src/crewai/task.py b/lib/crewai/src/crewai/task.py index 745233895..301cf64ef 100644 --- a/lib/crewai/src/crewai/task.py +++ b/lib/crewai/src/crewai/task.py @@ -91,7 +91,7 @@ from crewai.utilities.guardrail_types import ( GuardrailType, GuardrailsType, ) -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.string_utils import interpolate_only @@ -970,7 +970,7 @@ class Task(BaseModel): tasks_slices = [description] - output = I18N_DEFAULT.slice("expected_output").format( + output = get_crew_i18n().slice("expected_output").format( expected_output=self.expected_output ) tasks_slices = [description, output] @@ -1042,7 +1042,7 @@ Follow these guidelines: raise ValueError(f"Error interpolating output_file path: {e!s}") from e if inputs.get("crew_chat_messages"): - conversation_instruction = I18N_DEFAULT.slice( + conversation_instruction = get_crew_i18n().slice( "conversation_history_instruction" ) @@ -1318,7 +1318,7 @@ Follow these guidelines: self.retry_count += 1 current_retry_count = self.retry_count - context = I18N_DEFAULT.errors("validation_error").format( + context = get_crew_i18n().errors("validation_error").format( guardrail_result_error=guardrail_result.error, task_output=task_output.raw, ) @@ -1429,7 +1429,7 @@ Follow these guidelines: self.retry_count += 1 current_retry_count = self.retry_count - context = I18N_DEFAULT.errors("validation_error").format( + context = get_crew_i18n().errors("validation_error").format( guardrail_result_error=guardrail_result.error, task_output=task_output.raw, ) diff --git a/lib/crewai/src/crewai/tools/agent_tools/add_image_tool.py b/lib/crewai/src/crewai/tools/agent_tools/add_image_tool.py index 8191144d9..c15410e4b 100644 --- a/lib/crewai/src/crewai/tools/agent_tools/add_image_tool.py +++ b/lib/crewai/src/crewai/tools/agent_tools/add_image_tool.py @@ -3,7 +3,7 @@ from typing import Any from pydantic import BaseModel, Field from crewai.tools.base_tool import BaseTool -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n class AddImageToolSchema(BaseModel): @@ -16,9 +16,9 @@ class AddImageToolSchema(BaseModel): class AddImageTool(BaseTool): """Tool for adding images to the content""" - name: str = Field(default_factory=lambda: I18N_DEFAULT.tools("add_image")["name"]) # type: ignore[index] + name: str = Field(default_factory=lambda: get_crew_i18n().tools("add_image")["name"]) # type: ignore[index] description: str = Field( - default_factory=lambda: I18N_DEFAULT.tools("add_image")["description"] # type: ignore[index] + default_factory=lambda: get_crew_i18n().tools("add_image")["description"] # type: ignore[index] ) args_schema: type[BaseModel] = AddImageToolSchema @@ -28,7 +28,7 @@ class AddImageTool(BaseTool): action: str | None = None, **kwargs: Any, ) -> dict[str, Any]: - action = action or I18N_DEFAULT.tools("add_image")["default_action"] # type: ignore + action = action or get_crew_i18n().tools("add_image")["default_action"] # type: ignore content = [ {"type": "text", "text": action}, { diff --git a/lib/crewai/src/crewai/tools/agent_tools/agent_tools.py b/lib/crewai/src/crewai/tools/agent_tools/agent_tools.py index 533217456..fd7fbdf5a 100644 --- a/lib/crewai/src/crewai/tools/agent_tools/agent_tools.py +++ b/lib/crewai/src/crewai/tools/agent_tools/agent_tools.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from crewai.tools.agent_tools.ask_question_tool import AskQuestionTool from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n if TYPE_CHECKING: @@ -25,12 +25,12 @@ class AgentTools: delegate_tool = DelegateWorkTool( agents=self.agents, - description=I18N_DEFAULT.tools("delegate_work").format(coworkers=coworkers), # type: ignore + description=get_crew_i18n().tools("delegate_work").format(coworkers=coworkers), # type: ignore ) ask_tool = AskQuestionTool( agents=self.agents, - description=I18N_DEFAULT.tools("ask_question").format(coworkers=coworkers), # type: ignore + description=get_crew_i18n().tools("ask_question").format(coworkers=coworkers), # type: ignore ) return [delegate_tool, ask_tool] diff --git a/lib/crewai/src/crewai/tools/agent_tools/base_agent_tools.py b/lib/crewai/src/crewai/tools/agent_tools/base_agent_tools.py index 17e44e57a..dba79c670 100644 --- a/lib/crewai/src/crewai/tools/agent_tools/base_agent_tools.py +++ b/lib/crewai/src/crewai/tools/agent_tools/base_agent_tools.py @@ -6,7 +6,7 @@ from pydantic import Field from crewai.agents.agent_builder.base_agent import BaseAgent from crewai.task import Task from crewai.tools.base_tool import BaseTool -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ class BaseAgentTool(BaseTool): ) except (AttributeError, ValueError) as e: # Handle specific exceptions that might occur during role name processing - return I18N_DEFAULT.errors("agent_tool_unexisting_coworker").format( + return get_crew_i18n().errors("agent_tool_unexisting_coworker").format( coworkers="\n".join( [ f"- {self.sanitize_agent_name(agent.role)}" @@ -102,7 +102,7 @@ class BaseAgentTool(BaseTool): if not agent: # No matching agent found after sanitization - return I18N_DEFAULT.errors("agent_tool_unexisting_coworker").format( + return get_crew_i18n().errors("agent_tool_unexisting_coworker").format( coworkers="\n".join( [ f"- {self.sanitize_agent_name(agent.role)}" @@ -117,7 +117,7 @@ class BaseAgentTool(BaseTool): task_with_assigned_agent = Task( description=task, agent=selected_agent, - expected_output=I18N_DEFAULT.slice("manager_request"), + expected_output=get_crew_i18n().slice("manager_request"), ) logger.debug( f"Created task for agent '{self.sanitize_agent_name(selected_agent.role)}': {task}" @@ -125,6 +125,6 @@ class BaseAgentTool(BaseTool): return selected_agent.execute_task(task_with_assigned_agent, context) except Exception as e: # Handle task creation or execution errors - return I18N_DEFAULT.errors("agent_tool_execution_error").format( + return get_crew_i18n().errors("agent_tool_execution_error").format( agent_role=self.sanitize_agent_name(selected_agent.role), error=str(e) ) diff --git a/lib/crewai/src/crewai/tools/memory_tools.py b/lib/crewai/src/crewai/tools/memory_tools.py index e790c93f1..eb5ebc569 100644 --- a/lib/crewai/src/crewai/tools/memory_tools.py +++ b/lib/crewai/src/crewai/tools/memory_tools.py @@ -7,7 +7,7 @@ from typing import Any from pydantic import BaseModel, Field from crewai.tools.base_tool import BaseTool -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n class RecallMemorySchema(BaseModel): @@ -117,14 +117,14 @@ def create_memory_tools(memory: Any) -> list[BaseTool]: tools: list[BaseTool] = [ RecallMemoryTool( memory=memory, - description=I18N_DEFAULT.tools("recall_memory"), + description=get_crew_i18n().tools("recall_memory"), ), ] if not memory.read_only: tools.append( RememberTool( memory=memory, - description=I18N_DEFAULT.tools("save_to_memory"), + description=get_crew_i18n().tools("save_to_memory"), ) ) return tools diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index 0a004059a..1a2fa8b72 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -29,7 +29,7 @@ from crewai.utilities.agent_utils import ( render_text_description_and_args, ) from crewai.utilities.converter import Converter -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.string_utils import sanitize_tool_name @@ -145,7 +145,7 @@ class ToolUsage: if ( isinstance(tool, CrewStructuredTool) and sanitize_tool_name(tool.name) - == sanitize_tool_name(I18N_DEFAULT.tools("add_image")["name"]) # type: ignore + == sanitize_tool_name(get_crew_i18n().tools("add_image")["name"]) # type: ignore ): try: return self._use(tool_string=tool_string, tool=tool, calling=calling) @@ -193,7 +193,7 @@ class ToolUsage: if ( isinstance(tool, CrewStructuredTool) and sanitize_tool_name(tool.name) - == sanitize_tool_name(I18N_DEFAULT.tools("add_image")["name"]) # type: ignore + == sanitize_tool_name(get_crew_i18n().tools("add_image")["name"]) # type: ignore ): try: return await self._ause( @@ -229,7 +229,7 @@ class ToolUsage: """ if self._check_tool_repeated_usage(calling=calling): try: - result = I18N_DEFAULT.errors("task_repeated_usage").format( + result = get_crew_i18n().errors("task_repeated_usage").format( tool_names=self.tools_names ) self._telemetry.tool_repeated_usage( @@ -414,7 +414,7 @@ class ToolUsage: self._run_attempts += 1 if self._run_attempts > self._max_parsing_attempts: self._telemetry.tool_usage_error(llm=self.function_calling_llm) - error_message = I18N_DEFAULT.errors( + error_message = get_crew_i18n().errors( "tool_usage_exception" ).format( error=e, @@ -422,7 +422,7 @@ class ToolUsage: tool_inputs=tool.description, ) result = ToolUsageError( - f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}" + f"\n{error_message}.\nMoving on then. {get_crew_i18n().slice('format').format(tool_names=self.tools_names)}" ).message if self.task: self.task.increment_tools_errors() @@ -460,7 +460,7 @@ class ToolUsage: # Repeated usage check happens before event emission - safe to return early if self._check_tool_repeated_usage(calling=calling): try: - result = I18N_DEFAULT.errors("task_repeated_usage").format( + result = get_crew_i18n().errors("task_repeated_usage").format( tool_names=self.tools_names ) self._telemetry.tool_repeated_usage( @@ -647,7 +647,7 @@ class ToolUsage: self._run_attempts += 1 if self._run_attempts > self._max_parsing_attempts: self._telemetry.tool_usage_error(llm=self.function_calling_llm) - error_message = I18N_DEFAULT.errors( + error_message = get_crew_i18n().errors( "tool_usage_exception" ).format( error=e, @@ -655,7 +655,7 @@ class ToolUsage: tool_inputs=tool.description, ) result = ToolUsageError( - f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}" + f"\n{error_message}.\nMoving on then. {get_crew_i18n().slice('format').format(tool_names=self.tools_names)}" ).message if self.task: self.task.increment_tools_errors() @@ -698,7 +698,7 @@ class ToolUsage: def _remember_format(self, result: str) -> str: result = str(result) - result += "\n\n" + I18N_DEFAULT.slice("tools").format( + result += "\n\n" + get_crew_i18n().slice("tools").format( tools=self.tools_description, tool_names=self.tools_names ) return result @@ -824,12 +824,12 @@ class ToolUsage: except Exception: if raise_error: raise - return ToolUsageError(f"{I18N_DEFAULT.errors('tool_arguments_error')}") + return ToolUsageError(f"{get_crew_i18n().errors('tool_arguments_error')}") if not isinstance(arguments, dict): if raise_error: raise - return ToolUsageError(f"{I18N_DEFAULT.errors('tool_arguments_error')}") + return ToolUsageError(f"{get_crew_i18n().errors('tool_arguments_error')}") return ToolCalling( tool_name=sanitize_tool_name(tool.name), @@ -855,7 +855,7 @@ class ToolUsage: if self.agent and self.agent.verbose: PRINTER.print(content=f"\n\n{e}\n", color="red") return ToolUsageError( - f"{I18N_DEFAULT.errors('tool_usage_error').format(error=e)}\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}" + f"{get_crew_i18n().errors('tool_usage_error').format(error=e)}\nMoving on then. {get_crew_i18n().slice('format').format(tool_names=self.tools_names)}" ) return self._tool_calling(tool_string) diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 3cb72331c..b388f5559 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -33,7 +33,7 @@ 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.i18n import get_crew_i18n 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 @@ -313,10 +313,10 @@ def handle_max_iterations_exceeded( if formatted_answer and hasattr(formatted_answer, "text"): assistant_message = ( - formatted_answer.text + f"\n{I18N_DEFAULT.errors('force_final_answer')}" + formatted_answer.text + f"\n{get_crew_i18n().errors('force_final_answer')}" ) else: - assistant_message = I18N_DEFAULT.errors("force_final_answer") + assistant_message = get_crew_i18n().errors("force_final_answer") messages.append(format_message_for_llm(assistant_message, role="assistant")) @@ -902,10 +902,10 @@ async def _asummarize_chunks( conversation_text = _format_messages_for_summary(chunk) summarization_messages = [ format_message_for_llm( - I18N_DEFAULT.slice("summarizer_system_message"), role="system" + get_crew_i18n().slice("summarizer_system_message"), role="system" ), format_message_for_llm( - I18N_DEFAULT.slice("summarize_instruction").format( + get_crew_i18n().slice("summarize_instruction").format( conversation=conversation_text ), ), @@ -972,10 +972,10 @@ def summarize_messages( conversation_text = _format_messages_for_summary(chunk) summarization_messages = [ format_message_for_llm( - I18N_DEFAULT.slice("summarizer_system_message"), role="system" + get_crew_i18n().slice("summarizer_system_message"), role="system" ), format_message_for_llm( - I18N_DEFAULT.slice("summarize_instruction").format( + get_crew_i18n().slice("summarize_instruction").format( conversation=conversation_text ), ), @@ -1005,7 +1005,7 @@ def summarize_messages( messages.extend(system_messages) summary_message = format_message_for_llm( - I18N_DEFAULT.slice("summary").format(merged_summary=merged_summary) + get_crew_i18n().slice("summary").format(merged_summary=merged_summary) ) if preserved_files: summary_message["files"] = preserved_files diff --git a/lib/crewai/src/crewai/utilities/converter.py b/lib/crewai/src/crewai/utilities/converter.py index d31b76f48..d3568148f 100644 --- a/lib/crewai/src/crewai/utilities/converter.py +++ b/lib/crewai/src/crewai/utilities/converter.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError from typing_extensions import Unpack from crewai.agents.agent_builder.utilities.base_output_converter import OutputConverter -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.internal_instructor import InternalInstructor from crewai.utilities.pydantic_schema_utils import generate_model_description @@ -22,7 +22,7 @@ if TYPE_CHECKING: from crewai.llms.base_llm import BaseLLM _JSON_PATTERN: Final[re.Pattern[str]] = re.compile(r"({.*})", re.DOTALL) -_I18N = I18N_DEFAULT + class ConverterError(Exception): @@ -548,14 +548,14 @@ def get_conversion_instructions( ): schema_dict = generate_model_description(model) schema = json.dumps(schema_dict, indent=2) - formatted_task_instructions = _I18N.slice("formatted_task_instructions").format( + formatted_task_instructions = get_crew_i18n().slice("formatted_task_instructions").format( output_format=schema ) instructions += formatted_task_instructions else: model_description = generate_model_description(model) schema_json = json.dumps(model_description, indent=2) - formatted_task_instructions = _I18N.slice("formatted_task_instructions").format( + formatted_task_instructions = get_crew_i18n().slice("formatted_task_instructions").format( output_format=schema_json ) instructions += formatted_task_instructions diff --git a/lib/crewai/src/crewai/utilities/evaluators/task_evaluator.py b/lib/crewai/src/crewai/utilities/evaluators/task_evaluator.py index 5915c2346..522245445 100644 --- a/lib/crewai/src/crewai/utilities/evaluators/task_evaluator.py +++ b/lib/crewai/src/crewai/utilities/evaluators/task_evaluator.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from crewai.events.event_bus import crewai_event_bus from crewai.events.types.task_events import TaskEvaluationEvent from crewai.utilities.converter import Converter -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.pydantic_schema_utils import generate_model_description from crewai.utilities.training_converter import TrainingConverter @@ -98,7 +98,7 @@ class TaskEvaluator: if not self.llm.supports_function_calling(): # type: ignore[union-attr] schema_dict = generate_model_description(TaskEvaluation) - output_schema: str = I18N_DEFAULT.slice( + output_schema: str = get_crew_i18n().slice( "formatted_task_instructions" ).format(output_format=json.dumps(schema_dict, indent=2)) instructions = f"{instructions}\n\n{output_schema}" @@ -172,7 +172,7 @@ class TaskEvaluator: if not self.llm.supports_function_calling(): # type: ignore[union-attr] schema_dict = generate_model_description(TrainingTaskEvaluation) - output_schema: str = I18N_DEFAULT.slice( + output_schema: str = get_crew_i18n().slice( "formatted_task_instructions" ).format(output_format=json.dumps(schema_dict, indent=2)) instructions = f"{instructions}\n\n{output_schema}" diff --git a/lib/crewai/src/crewai/utilities/i18n.py b/lib/crewai/src/crewai/utilities/i18n.py index 8d091dd52..160339ed1 100644 --- a/lib/crewai/src/crewai/utilities/i18n.py +++ b/lib/crewai/src/crewai/utilities/i18n.py @@ -1,5 +1,6 @@ """Internationalization support for CrewAI prompts and messages.""" +import contextvars from functools import lru_cache import json import os @@ -145,3 +146,33 @@ def get_i18n(prompt_file: str | None = None) -> I18N: I18N_DEFAULT: I18N = get_i18n() + + +_crew_i18n_var: contextvars.ContextVar[I18N | None] = contextvars.ContextVar( + "_crew_i18n", default=None +) + + +def get_crew_i18n() -> I18N: + """Return the crew-scoped I18N override if set, otherwise ``I18N_DEFAULT``. + + When a :class:`Crew` with a ``prompt_file`` is kicked off, the custom + I18N instance is pushed into a :class:`contextvars.ContextVar` so that + all code running during that crew execution automatically picks up the + custom prompts. + """ + return _crew_i18n_var.get() or I18N_DEFAULT + + +def set_crew_i18n(i18n: I18N | None) -> contextvars.Token[I18N | None]: + """Set (or clear) the crew-scoped I18N override. + + Returns a token that can be passed to :func:`reset_crew_i18n` for + proper cleanup. + """ + return _crew_i18n_var.set(i18n) + + +def reset_crew_i18n(token: contextvars.Token[I18N | None]) -> None: + """Reset the crew-scoped I18N override to its previous value.""" + _crew_i18n_var.reset(token) diff --git a/lib/crewai/src/crewai/utilities/prompts.py b/lib/crewai/src/crewai/utilities/prompts.py index db89b9c16..b66ae453b 100644 --- a/lib/crewai/src/crewai/utilities/prompts.py +++ b/lib/crewai/src/crewai/utilities/prompts.py @@ -6,7 +6,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n class StandardPromptResult(BaseModel): @@ -158,13 +158,13 @@ class Prompts(BaseModel): if not system_template or not prompt_template: # If any of the required templates are missing, fall back to the default format prompt_parts: list[str] = [ - I18N_DEFAULT.slice(component) for component in components + get_crew_i18n().slice(component) for component in components ] prompt = "".join(prompt_parts) else: # All templates are provided, use them template_parts: list[str] = [ - I18N_DEFAULT.slice(component) + get_crew_i18n().slice(component) for component in components if component != "task" ] @@ -172,7 +172,7 @@ class Prompts(BaseModel): "{{ .System }}", "".join(template_parts) ) prompt = prompt_template.replace( - "{{ .Prompt }}", "".join(I18N_DEFAULT.slice("task")) + "{{ .Prompt }}", "".join(get_crew_i18n().slice("task")) ) # Handle missing response_template if response_template: diff --git a/lib/crewai/src/crewai/utilities/reasoning_handler.py b/lib/crewai/src/crewai/utilities/reasoning_handler.py index ab3cbba16..21ab20542 100644 --- a/lib/crewai/src/crewai/utilities/reasoning_handler.py +++ b/lib/crewai/src/crewai/utilities/reasoning_handler.py @@ -15,7 +15,7 @@ from crewai.events.types.reasoning_events import ( AgentReasoningStartedEvent, ) from crewai.llm import LLM -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.llm_utils import create_llm from crewai.utilities.planning_types import PlanStep from crewai.utilities.string_utils import sanitize_tool_name @@ -482,17 +482,17 @@ class AgentReasoning: """Get the system prompt for planning. Returns: - The system prompt, either custom or from I18N_DEFAULT. + The system prompt, either custom or from get_crew_i18n(). """ if self.config.system_prompt is not None: return self.config.system_prompt # Try new "planning" section first, fall back to "reasoning" for compatibility try: - return I18N_DEFAULT.retrieve("planning", "system_prompt") + return get_crew_i18n().retrieve("planning", "system_prompt") except (KeyError, AttributeError): # Fallback to reasoning section for backward compatibility - return I18N_DEFAULT.retrieve("reasoning", "initial_plan").format( + return get_crew_i18n().retrieve("reasoning", "initial_plan").format( role=self.agent.role, goal=self.agent.goal, backstory=self._get_agent_backstory(), @@ -528,7 +528,7 @@ class AgentReasoning: # Try new "planning" section first try: - return I18N_DEFAULT.retrieve("planning", "create_plan_prompt").format( + return get_crew_i18n().retrieve("planning", "create_plan_prompt").format( description=self.description, expected_output=self.expected_output, tools=available_tools, @@ -536,7 +536,7 @@ class AgentReasoning: ) except (KeyError, AttributeError): # Fallback to reasoning section for backward compatibility - return I18N_DEFAULT.retrieve("reasoning", "create_plan_prompt").format( + return get_crew_i18n().retrieve("reasoning", "create_plan_prompt").format( role=self.agent.role, goal=self.agent.goal, backstory=self._get_agent_backstory(), @@ -585,12 +585,12 @@ class AgentReasoning: # Try new "planning" section first try: - return I18N_DEFAULT.retrieve("planning", "refine_plan_prompt").format( + return get_crew_i18n().retrieve("planning", "refine_plan_prompt").format( current_plan=current_plan, ) except (KeyError, AttributeError): # Fallback to reasoning section for backward compatibility - return I18N_DEFAULT.retrieve("reasoning", "refine_plan_prompt").format( + return get_crew_i18n().retrieve("reasoning", "refine_plan_prompt").format( role=self.agent.role, goal=self.agent.goal, backstory=self._get_agent_backstory(), @@ -643,7 +643,7 @@ def _call_llm_with_reasoning_prompt( Returns: The LLM response. """ - system_prompt = I18N_DEFAULT.retrieve("reasoning", plan_type).format( + system_prompt = get_crew_i18n().retrieve("reasoning", plan_type).format( role=reasoning_agent.role, goal=reasoning_agent.goal, backstory=backstory, diff --git a/lib/crewai/src/crewai/utilities/tool_utils.py b/lib/crewai/src/crewai/utilities/tool_utils.py index c7a469468..52b2ff811 100644 --- a/lib/crewai/src/crewai/utilities/tool_utils.py +++ b/lib/crewai/src/crewai/utilities/tool_utils.py @@ -13,7 +13,7 @@ from crewai.security.fingerprint import Fingerprint from crewai.tools.structured_tool import CrewStructuredTool from crewai.tools.tool_types import ToolResult from crewai.tools.tool_usage import ToolUsage, ToolUsageError -from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.i18n import get_crew_i18n from crewai.utilities.logger import Logger from crewai.utilities.string_utils import sanitize_tool_name @@ -140,7 +140,7 @@ async def aexecute_tool_and_check_finality( return ToolResult(modified_result, tool.result_as_answer) - tool_result = I18N_DEFAULT.errors("wrong_tool_name").format( + tool_result = get_crew_i18n().errors("wrong_tool_name").format( tool=sanitized_tool_name, tools=", ".join(tool_name_to_tool_map.keys()), ) @@ -259,7 +259,7 @@ def execute_tool_and_check_finality( return ToolResult(modified_result, tool.result_as_answer) - tool_result = I18N_DEFAULT.errors("wrong_tool_name").format( + tool_result = get_crew_i18n().errors("wrong_tool_name").format( tool=sanitized_tool_name, tools=", ".join(tool_name_to_tool_map.keys()), ) diff --git a/lib/crewai/tests/utilities/custom_prompts.json b/lib/crewai/tests/utilities/custom_prompts.json new file mode 100644 index 000000000..056be0e08 --- /dev/null +++ b/lib/crewai/tests/utilities/custom_prompts.json @@ -0,0 +1,101 @@ +{ + "hierarchical_manager_agent": { + "role": "CUSTOM_role", + "goal": "CUSTOM_goal", + "backstory": "CUSTOM_backstory" + }, + "slices": { + "observation": "CUSTOM_observation", + "task": "CUSTOM_task", + "memory": "CUSTOM_memory", + "role_playing": "CUSTOM_role_playing", + "tools": "CUSTOM_tools", + "no_tools": "CUSTOM_no_tools", + "task_no_tools": "CUSTOM_task_no_tools", + "native_tools": "CUSTOM_native_tools", + "native_task": "CUSTOM_native_task", + "post_tool_reasoning": "CUSTOM_post_tool_reasoning", + "format": "CUSTOM_format", + "final_answer_format": "CUSTOM_final_answer_format", + "format_without_tools": "CUSTOM_format_without_tools", + "task_with_context": "CUSTOM_task_with_context", + "expected_output": "CUSTOM_expected_output", + "human_feedback": "CUSTOM_human_feedback", + "getting_input": "CUSTOM_getting_input", + "summarizer_system_message": "CUSTOM_summarizer_system_message", + "summarize_instruction": "CUSTOM_summarize_instruction", + "summary": "CUSTOM_summary", + "manager_request": "CUSTOM_manager_request", + "formatted_task_instructions": "CUSTOM_formatted_task_instructions", + "conversation_history_instruction": "CUSTOM_conversation_history_instruction", + "feedback_instructions": "CUSTOM_feedback_instructions", + "lite_agent_system_prompt_with_tools": "CUSTOM_lite_agent_system_prompt_with_tools", + "lite_agent_system_prompt_without_tools": "CUSTOM_lite_agent_system_prompt_without_tools", + "lite_agent_response_format": "CUSTOM_lite_agent_response_format", + "knowledge_search_query": "CUSTOM_knowledge_search_query", + "knowledge_search_query_system_prompt": "CUSTOM_knowledge_search_query_system_prompt", + "human_feedback_collapse": "CUSTOM_human_feedback_collapse", + "hitl_pre_review_system": "CUSTOM_hitl_pre_review_system", + "hitl_pre_review_user": "CUSTOM_hitl_pre_review_user", + "hitl_distill_system": "CUSTOM_hitl_distill_system", + "hitl_distill_user": "CUSTOM_hitl_distill_user" + }, + "errors": { + "force_final_answer_error": "CUSTOM_force_final_answer_error", + "force_final_answer": "CUSTOM_force_final_answer", + "agent_tool_unexisting_coworker": "CUSTOM_agent_tool_unexisting_coworker", + "task_repeated_usage": "CUSTOM_task_repeated_usage", + "tool_usage_error": "CUSTOM_tool_usage_error", + "tool_arguments_error": "CUSTOM_tool_arguments_error", + "wrong_tool_name": "CUSTOM_wrong_tool_name", + "tool_usage_exception": "CUSTOM_tool_usage_exception", + "agent_tool_execution_error": "CUSTOM_agent_tool_execution_error", + "validation_error": "CUSTOM_validation_error" + }, + "tools": { + "delegate_work": "CUSTOM_delegate_work", + "ask_question": "CUSTOM_ask_question", + "add_image": { + "name": "CUSTOM_name", + "description": "CUSTOM_description", + "default_action": "CUSTOM_default_action" + }, + "recall_memory": "CUSTOM_recall_memory", + "save_to_memory": "CUSTOM_save_to_memory" + }, + "memory": { + "query_system": "CUSTOM_query_system", + "extract_memories_system": "CUSTOM_extract_memories_system", + "extract_memories_user": "CUSTOM_extract_memories_user", + "query_user": "CUSTOM_query_user", + "save_system": "CUSTOM_save_system", + "save_user": "CUSTOM_save_user", + "consolidation_system": "CUSTOM_consolidation_system", + "consolidation_user": "CUSTOM_consolidation_user" + }, + "reasoning": { + "initial_plan": "CUSTOM_initial_plan", + "refine_plan": "CUSTOM_refine_plan", + "create_plan_prompt": "CUSTOM_create_plan_prompt", + "refine_plan_prompt": "CUSTOM_refine_plan_prompt" + }, + "planning": { + "system_prompt": "CUSTOM_system_prompt", + "create_plan_prompt": "CUSTOM_create_plan_prompt", + "refine_plan_prompt": "CUSTOM_refine_plan_prompt", + "observation_system_prompt": "CUSTOM_observation_system_prompt", + "observation_user_prompt": "CUSTOM_observation_user_prompt", + "step_executor_system_prompt": "CUSTOM_step_executor_system_prompt", + "step_executor_tools_section": "CUSTOM_step_executor_tools_section", + "step_executor_user_prompt": "CUSTOM_step_executor_user_prompt", + "step_executor_suggested_tool": "CUSTOM_step_executor_suggested_tool", + "step_executor_context_header": "CUSTOM_step_executor_context_header", + "step_executor_context_entry": "CUSTOM_step_executor_context_entry", + "step_executor_complete_step": "CUSTOM_step_executor_complete_step", + "todo_system_prompt": "CUSTOM_todo_system_prompt", + "synthesis_system_prompt": "CUSTOM_synthesis_system_prompt", + "synthesis_user_prompt": "CUSTOM_synthesis_user_prompt", + "replan_enhancement_prompt": "CUSTOM_replan_enhancement_prompt", + "step_executor_task_context": "CUSTOM_step_executor_task_context" + } +} diff --git a/lib/crewai/tests/utilities/test_prompt_file_propagation.py b/lib/crewai/tests/utilities/test_prompt_file_propagation.py new file mode 100644 index 000000000..d39b0de6b --- /dev/null +++ b/lib/crewai/tests/utilities/test_prompt_file_propagation.py @@ -0,0 +1,293 @@ +"""Tests for Crew.prompt_file propagation to all components. + +Verifies fix for https://github.com/crewAIInc/crewAI/issues/5931 +""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from crewai.utilities.i18n import ( + I18N, + I18N_DEFAULT, + get_crew_i18n, + get_i18n, + reset_crew_i18n, + set_crew_i18n, +) + +CUSTOM_PROMPTS = os.path.join(os.path.dirname(__file__), "custom_prompts.json") + + +class TestCrewI18nContextVar: + """Test the context variable mechanism for crew-scoped I18N.""" + + def test_get_crew_i18n_returns_default_when_no_override(self): + assert get_crew_i18n() is I18N_DEFAULT + + def test_set_and_get_crew_i18n(self): + custom = get_i18n(CUSTOM_PROMPTS) + token = set_crew_i18n(custom) + try: + assert get_crew_i18n() is custom + assert get_crew_i18n().slice("role_playing") == "CUSTOM_role_playing" + finally: + reset_crew_i18n(token) + + def test_reset_crew_i18n_restores_default(self): + custom = get_i18n(CUSTOM_PROMPTS) + token = set_crew_i18n(custom) + reset_crew_i18n(token) + assert get_crew_i18n() is I18N_DEFAULT + + def test_nested_set_and_reset(self): + custom1 = I18N(prompt_file=CUSTOM_PROMPTS) + custom2 = I18N(prompt_file=CUSTOM_PROMPTS) + + token1 = set_crew_i18n(custom1) + assert get_crew_i18n() is custom1 + + token2 = set_crew_i18n(custom2) + assert get_crew_i18n() is custom2 + + reset_crew_i18n(token2) + assert get_crew_i18n() is custom1 + + reset_crew_i18n(token1) + assert get_crew_i18n() is I18N_DEFAULT + + +class TestPromptsUsesCrewI18n: + """Test that the Prompts class picks up the crew-scoped I18N.""" + + def test_prompts_build_prompt_uses_crew_i18n(self): + from crewai.utilities.prompts import Prompts + + agent = MagicMock() + agent.role = "TestRole" + agent.goal = "TestGoal" + agent.backstory = "TestBackstory" + agent.skills = [] + + custom = get_i18n(CUSTOM_PROMPTS) + token = set_crew_i18n(custom) + try: + prompts = Prompts(agent=agent, has_tools=False) + result = prompts.task_execution() + assert "CUSTOM_role_playing" in result.prompt + assert "CUSTOM_no_tools" in result.prompt + finally: + reset_crew_i18n(token) + + def test_prompts_uses_default_when_no_override(self): + from crewai.utilities.prompts import Prompts + + agent = MagicMock() + agent.role = "TestRole" + agent.goal = "TestGoal" + agent.backstory = "TestBackstory" + agent.skills = [] + + prompts = Prompts(agent=agent, has_tools=False) + result = prompts.task_execution() + # Should use default en.json prompts, not custom ones + assert "CUSTOM_" not in result.prompt + assert "TestRole" in result.prompt + + +class TestTaskPromptUsesCrewI18n: + """Test that Task.prompt() picks up the crew-scoped I18N.""" + + def test_task_prompt_uses_crew_i18n(self): + from crewai.task import Task + + task = Task( + description="Test task", + expected_output="Test output", + ) + + custom = get_i18n(CUSTOM_PROMPTS) + token = set_crew_i18n(custom) + try: + prompt = task.prompt() + assert "CUSTOM_expected_output" in prompt + finally: + reset_crew_i18n(token) + + def test_task_prompt_uses_default_when_no_override(self): + from crewai.task import Task + + task = Task( + description="Test task", + expected_output="Test output", + ) + prompt = task.prompt() + assert "CUSTOM_" not in prompt + assert "Test output" in prompt + + +class TestAgentToolsUseCrewI18n: + """Test that delegation tools pick up the crew-scoped I18N.""" + + def test_agent_tools_use_crew_i18n(self): + from crewai.agent.core import Agent + from crewai.tools.agent_tools.agent_tools import AgentTools + + agent = Agent( + role="TestAgent", + goal="Test goal", + backstory="Test backstory", + llm="gpt-4o", + ) + + custom = get_i18n(CUSTOM_PROMPTS) + token = set_crew_i18n(custom) + try: + tools = AgentTools(agents=[agent]).tools() + descriptions = [t.description for t in tools] + assert any("CUSTOM_delegate_work" in d for d in descriptions) + assert any("CUSTOM_ask_question" in d for d in descriptions) + finally: + reset_crew_i18n(token) + + +class TestCrewKickoffSetsI18nContext: + """Test that Crew.kickoff() properly sets and resets the I18N context.""" + + @patch("crewai.crew.Crew._run_sequential_process") + @patch("crewai.crews.utils.setup_agents") + @patch("crewai.crew.Crew.calculate_usage_metrics") + def test_kickoff_sets_i18n_for_custom_prompt_file( + self, mock_metrics, mock_setup, mock_seq + ): + from crewai.agent.core import Agent + from crewai.crew import Crew + from crewai.crews.crew_output import CrewOutput + from crewai.task import Task + + mock_metrics.return_value = MagicMock() + + from crewai.types.usage_metrics import UsageMetrics + + captured_i18n = [] + + def capture_i18n(*args, **kwargs): + captured_i18n.append(get_crew_i18n()) + return CrewOutput( + raw="done", + tasks_output=[], + json_dict=None, + pydantic=None, + token_usage=UsageMetrics(), + ) + + mock_seq.side_effect = capture_i18n + + agent = Agent( + role="Researcher", + goal="Research stuff", + backstory="Expert researcher", + llm="gpt-4o", + ) + task = Task( + description="Do research", + expected_output="A report", + agent=agent, + ) + crew = Crew( + agents=[agent], + tasks=[task], + prompt_file=CUSTOM_PROMPTS, + ) + + crew.kickoff() + + assert len(captured_i18n) == 1 + assert captured_i18n[0].slice("role_playing") == "CUSTOM_role_playing" + # After kickoff, context should be reset + assert get_crew_i18n() is I18N_DEFAULT + + @patch("crewai.crew.Crew._run_sequential_process") + @patch("crewai.crews.utils.setup_agents") + @patch("crewai.crew.Crew.calculate_usage_metrics") + def test_kickoff_resets_i18n_on_exception( + self, mock_metrics, mock_setup, mock_seq + ): + from crewai.agent.core import Agent + from crewai.crew import Crew + from crewai.task import Task + + mock_seq.side_effect = RuntimeError("boom") + + agent = Agent( + role="Researcher", + goal="Research stuff", + backstory="Expert researcher", + llm="gpt-4o", + ) + task = Task( + description="Do research", + expected_output="A report", + agent=agent, + ) + crew = Crew( + agents=[agent], + tasks=[task], + prompt_file=CUSTOM_PROMPTS, + ) + + with pytest.raises(RuntimeError, match="boom"): + crew.kickoff() + + # Context must be reset even on exception + assert get_crew_i18n() is I18N_DEFAULT + + @patch("crewai.crew.Crew._run_sequential_process") + @patch("crewai.crews.utils.setup_agents") + @patch("crewai.crew.Crew.calculate_usage_metrics") + def test_kickoff_without_prompt_file_uses_default( + self, mock_metrics, mock_setup, mock_seq + ): + from crewai.agent.core import Agent + from crewai.crew import Crew + from crewai.crews.crew_output import CrewOutput + from crewai.task import Task + from crewai.types.usage_metrics import UsageMetrics + + mock_metrics.return_value = MagicMock() + + captured_i18n = [] + + def capture_i18n(*args, **kwargs): + captured_i18n.append(get_crew_i18n()) + return CrewOutput( + raw="done", + tasks_output=[], + json_dict=None, + pydantic=None, + token_usage=UsageMetrics(), + ) + + mock_seq.side_effect = capture_i18n + + agent = Agent( + role="Researcher", + goal="Research stuff", + backstory="Expert researcher", + llm="gpt-4o", + ) + task = Task( + description="Do research", + expected_output="A report", + agent=agent, + ) + crew = Crew( + agents=[agent], + tasks=[task], + ) + + crew.kickoff() + + assert len(captured_i18n) == 1 + assert captured_i18n[0] is I18N_DEFAULT