From fcb5b19b2ec08dcb61589db8d1191554cd8906da Mon Sep 17 00:00:00 2001 From: Daniel Barreto Date: Tue, 11 Nov 2025 19:33:33 -0300 Subject: [PATCH 1/3] Enhance schema description of QdrantVectorSearchTool (#3891) --- .../qdrant_vector_search_tool/qdrant_search_tool.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/crewai-tools/src/crewai_tools/tools/qdrant_vector_search_tool/qdrant_search_tool.py b/lib/crewai-tools/src/crewai_tools/tools/qdrant_vector_search_tool/qdrant_search_tool.py index b0b4d1a77..063af07e3 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/qdrant_vector_search_tool/qdrant_search_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/qdrant_vector_search_tool/qdrant_search_tool.py @@ -12,12 +12,16 @@ from pydantic.types import ImportString class QdrantToolSchema(BaseModel): - query: str = Field(..., description="Query to search in Qdrant DB") + query: str = Field( + ..., description="Query to search in Qdrant DB - always required." + ) filter_by: str | None = Field( - default=None, description="Parameter to filter the search by." + default=None, + description="Parameter to filter the search by. When filtering, needs to be used in conjunction with filter_value.", ) filter_value: Any | None = Field( - default=None, description="Value to filter the search by." + default=None, + description="Value to filter the search by. When filtering, needs to be used in conjunction with filter_by.", ) From c205d2e8de6f7c9fe47d22cc91e5852fdf211fe1 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:38:13 -0800 Subject: [PATCH 2/3] feat: implement before and after LLM call hooks in CrewAgentExecutor (#3893) - Added support for before and after LLM call hooks to allow modification of messages and responses during LLM interactions. - Introduced LLMCallHookContext to provide hooks with access to the executor state, enabling in-place modifications of messages. - Updated get_llm_response function to utilize the new hooks, ensuring that modifications persist across iterations. - Enhanced tests to verify the functionality of the hooks and their error handling capabilities, ensuring robust execution flow. --- .../src/crewai/agents/crew_agent_executor.py | 9 + .../src/crewai/utilities/agent_utils.py | 99 +++++- .../src/crewai/utilities/llm_call_hooks.py | 115 +++++++ lib/crewai/tests/agents/test_agent.py | 290 ++++++++++++++++++ ...after_llm_call_hook_modifies_messages.yaml | 126 ++++++++ ..._modifies_messages_for_next_iteration.yaml | 222 ++++++++++++++ ...efore_llm_call_hook_modifies_messages.yaml | 127 ++++++++ ..._hooks_can_modify_executor_attributes.yaml | 262 ++++++++++++++++ .../test_llm_call_hooks_error_handling.yaml | 159 ++++++++++ .../test_llm_call_hooks_with_crew.yaml | 182 +++++++++++ 10 files changed, 1590 insertions(+), 1 deletion(-) create mode 100644 lib/crewai/src/crewai/utilities/llm_call_hooks.py create mode 100644 lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages.yaml create mode 100644 lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages_for_next_iteration.yaml create mode 100644 lib/crewai/tests/cassettes/test_before_llm_call_hook_modifies_messages.yaml create mode 100644 lib/crewai/tests/cassettes/test_llm_call_hooks_can_modify_executor_attributes.yaml create mode 100644 lib/crewai/tests/cassettes/test_llm_call_hooks_error_handling.yaml create mode 100644 lib/crewai/tests/cassettes/test_llm_call_hooks_with_crew.yaml diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index 5b806658c..d44c984d4 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -38,6 +38,10 @@ from crewai.utilities.agent_utils import ( ) from crewai.utilities.constants import TRAINING_DATA_FILE from crewai.utilities.i18n import I18N, get_i18n +from crewai.utilities.llm_call_hooks import ( + get_after_llm_call_hooks, + get_before_llm_call_hooks, +) from crewai.utilities.printer import Printer from crewai.utilities.tool_utils import execute_tool_and_check_finality from crewai.utilities.training_handler import CrewTrainingHandler @@ -130,6 +134,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin): self.messages: list[LLMMessage] = [] self.iterations = 0 self.log_error_after = 3 + self.before_llm_call_hooks: list[Callable] = [] + self.after_llm_call_hooks: list[Callable] = [] + self.before_llm_call_hooks.extend(get_before_llm_call_hooks()) + self.after_llm_call_hooks.extend(get_after_llm_call_hooks()) if self.llm: # This may be mutating the shared llm object and needs further evaluation existing_stop = getattr(self.llm, "stop", []) @@ -226,6 +234,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin): from_task=self.task, from_agent=self.agent, response_model=self.response_model, + executor_context=self, ) formatted_answer = process_llm_response(answer, self.use_stop_words) # type: ignore[assignment] diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index a6403c315..d9b4f148b 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -33,6 +33,7 @@ from crewai.utilities.types import LLMMessage if TYPE_CHECKING: from crewai.agent import Agent + from crewai.agents.crew_agent_executor import CrewAgentExecutor from crewai.lite_agent import LiteAgent from crewai.llm import LLM from crewai.task import Task @@ -236,6 +237,7 @@ def get_llm_response( from_task: Task | None = None, from_agent: Agent | LiteAgent | None = None, response_model: type[BaseModel] | None = None, + executor_context: CrewAgentExecutor | None = None, ) -> str: """Call the LLM and return the response, handling any invalid responses. @@ -247,6 +249,7 @@ def get_llm_response( from_task: Optional task context for the LLM call from_agent: Optional agent context for the LLM call response_model: Optional Pydantic model for structured outputs + executor_context: Optional executor context for hook invocation Returns: The response from the LLM as a string @@ -255,6 +258,11 @@ def get_llm_response( Exception: If an error occurs. ValueError: If the response is None or empty. """ + + if executor_context is not None: + _setup_before_llm_call_hooks(executor_context, printer) + messages = executor_context.messages + try: answer = llm.call( messages, @@ -272,7 +280,7 @@ def get_llm_response( ) raise ValueError("Invalid response from LLM call - None or empty.") - return answer + return _setup_after_llm_call_hooks(executor_context, answer, printer) def process_llm_response( @@ -661,3 +669,92 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]: else: attributes[key] = value return attributes + + +def _setup_before_llm_call_hooks( + executor_context: CrewAgentExecutor | None, printer: Printer +) -> None: + """Setup and invoke before_llm_call hooks for the executor context. + + Args: + executor_context: The executor context to setup the hooks for. + printer: Printer instance for error logging. + """ + if executor_context and executor_context.before_llm_call_hooks: + from crewai.utilities.llm_call_hooks import LLMCallHookContext + + original_messages = executor_context.messages + + hook_context = LLMCallHookContext(executor_context) + try: + for hook in executor_context.before_llm_call_hooks: + hook(hook_context) + except Exception as e: + printer.print( + content=f"Error in before_llm_call hook: {e}", + color="yellow", + ) + + if not isinstance(executor_context.messages, list): + printer.print( + content=( + "Warning: before_llm_call hook replaced messages with non-list. " + "Restoring original messages list. Hooks should modify messages in-place, " + "not replace the list (e.g., use context.messages.append() not context.messages = [])." + ), + color="yellow", + ) + if isinstance(original_messages, list): + executor_context.messages = original_messages + else: + executor_context.messages = [] + + +def _setup_after_llm_call_hooks( + executor_context: CrewAgentExecutor | None, + answer: str, + printer: Printer, +) -> str: + """Setup and invoke after_llm_call hooks for the executor context. + + Args: + executor_context: The executor context to setup the hooks for. + answer: The LLM response string. + printer: Printer instance for error logging. + + Returns: + The potentially modified response string. + """ + if executor_context and executor_context.after_llm_call_hooks: + from crewai.utilities.llm_call_hooks import LLMCallHookContext + + original_messages = executor_context.messages + + hook_context = LLMCallHookContext(executor_context, response=answer) + try: + for hook in executor_context.after_llm_call_hooks: + modified_response = hook(hook_context) + if modified_response is not None and isinstance(modified_response, str): + answer = modified_response + + except Exception as e: + printer.print( + content=f"Error in after_llm_call hook: {e}", + color="yellow", + ) + + if not isinstance(executor_context.messages, list): + printer.print( + content=( + "Warning: after_llm_call hook replaced messages with non-list. " + "Restoring original messages list. Hooks should modify messages in-place, " + "not replace the list (e.g., use context.messages.append() not context.messages = [])." + ), + color="yellow", + ) + if isinstance(original_messages, list): + executor_context.messages = original_messages + else: + executor_context.messages = [] + + return answer diff --git a/lib/crewai/src/crewai/utilities/llm_call_hooks.py b/lib/crewai/src/crewai/utilities/llm_call_hooks.py new file mode 100644 index 000000000..bf6b81b36 --- /dev/null +++ b/lib/crewai/src/crewai/utilities/llm_call_hooks.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from crewai.agents.crew_agent_executor import CrewAgentExecutor + + +class LLMCallHookContext: + """Context object passed to LLM call hooks with full executor access. + + Provides hooks with complete access to the executor state, allowing + modification of messages, responses, and executor attributes. + + Attributes: + executor: Full reference to the CrewAgentExecutor instance + messages: Direct reference to executor.messages (mutable list). + Can be modified in both before_llm_call and after_llm_call hooks. + Modifications in after_llm_call hooks persist to the next iteration, + allowing hooks to modify conversation history for subsequent LLM calls. + IMPORTANT: Modify messages in-place (e.g., append, extend, remove items). + Do NOT replace the list (e.g., context.messages = []), as this will break + the executor. Use context.messages.append() or context.messages.extend() + instead of assignment. + agent: Reference to the agent executing the task + task: Reference to the task being executed + crew: Reference to the crew instance + llm: Reference to the LLM instance + iterations: Current iteration count + response: LLM response string (only set for after_llm_call hooks). + Can be modified by returning a new string from after_llm_call hook. + """ + + def __init__( + self, + executor: CrewAgentExecutor, + response: str | None = None, + ) -> None: + """Initialize hook context with executor reference. + + Args: + executor: The CrewAgentExecutor instance + response: Optional response string (for after_llm_call hooks) + """ + self.executor = executor + self.messages = executor.messages + self.agent = executor.agent + self.task = executor.task + self.crew = executor.crew + self.llm = executor.llm + self.iterations = executor.iterations + self.response = response + + +# Global hook registries (optional convenience feature) +_before_llm_call_hooks: list[Callable[[LLMCallHookContext], None]] = [] +_after_llm_call_hooks: list[Callable[[LLMCallHookContext], str | None]] = [] + + +def register_before_llm_call_hook( + hook: Callable[[LLMCallHookContext], None], +) -> None: + """Register a global before_llm_call hook. + + Global hooks are added to all executors automatically. + This is a convenience function for registering hooks that should + apply to all LLM calls across all executors. + + Args: + hook: Function that receives LLMCallHookContext and can modify + context.messages directly. Should return None. + IMPORTANT: Modify messages in-place (append, extend, remove items). + Do NOT replace the list (context.messages = []), as this will break execution. + """ + _before_llm_call_hooks.append(hook) + + +def register_after_llm_call_hook( + hook: Callable[[LLMCallHookContext], str | None], +) -> None: + """Register a global after_llm_call hook. + + Global hooks are added to all executors automatically. + This is a convenience function for registering hooks that should + apply to all LLM calls across all executors. + + Args: + hook: Function that receives LLMCallHookContext and can modify: + - The response: Return modified response string or None to keep original + - The messages: Modify context.messages directly (mutable reference) + Both modifications are supported and can be used together. + IMPORTANT: Modify messages in-place (append, extend, remove items). + Do NOT replace the list (context.messages = []), as this will break execution. + """ + _after_llm_call_hooks.append(hook) + + +def get_before_llm_call_hooks() -> list[Callable[[LLMCallHookContext], None]]: + """Get all registered global before_llm_call hooks. + + Returns: + List of registered before hooks + """ + return _before_llm_call_hooks.copy() + + +def get_after_llm_call_hooks() -> list[Callable[[LLMCallHookContext], str | None]]: + """Get all registered global after_llm_call hooks. + + Returns: + List of registered after hooks + """ + return _after_llm_call_hooks.copy() diff --git a/lib/crewai/tests/agents/test_agent.py b/lib/crewai/tests/agents/test_agent.py index 4fd1f3b5b..21f08a336 100644 --- a/lib/crewai/tests/agents/test_agent.py +++ b/lib/crewai/tests/agents/test_agent.py @@ -2714,3 +2714,293 @@ def test_agent_without_apps_no_platform_tools(): tools = crew._prepare_tools(agent, task, []) assert tools == [] + + +@pytest.mark.vcr(filter_headers=["authorization"]) +def test_before_llm_call_hook_modifies_messages(): + """Test that before_llm_call hooks can modify messages.""" + from crewai.utilities.llm_call_hooks import LLMCallHookContext, register_before_llm_call_hook + + hook_called = False + original_message_count = 0 + + def before_hook(context: LLMCallHookContext) -> None: + nonlocal hook_called, original_message_count + hook_called = True + original_message_count = len(context.messages) + context.messages.append({ + "role": "user", + "content": "Additional context: This is a test modification." + }) + + register_before_llm_call_hook(before_hook) + + try: + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + allow_delegation=False, + ) + + task = Task( + description="Say hello", + expected_output="A greeting", + agent=agent, + ) + + result = agent.execute_task(task) + + assert hook_called, "before_llm_call hook should have been called" + assert len(agent.agent_executor.messages) > original_message_count + assert result is not None + finally: + pass + + +@pytest.mark.vcr(filter_headers=["authorization"]) +def test_after_llm_call_hook_modifies_messages_for_next_iteration(): + """Test that after_llm_call hooks can modify messages for the next iteration.""" + from crewai.utilities.llm_call_hooks import LLMCallHookContext, register_after_llm_call_hook + + hook_call_count = 0 + hook_iterations = [] + messages_added_in_iteration_0 = False + test_message_content = "HOOK_ADDED_MESSAGE_FOR_NEXT_ITERATION" + + def after_hook(context: LLMCallHookContext) -> str | None: + nonlocal hook_call_count, hook_iterations, messages_added_in_iteration_0 + hook_call_count += 1 + current_iteration = context.iterations + hook_iterations.append(current_iteration) + + if current_iteration == 0: + messages_before = len(context.messages) + context.messages.append({ + "role": "user", + "content": test_message_content + }) + messages_added_in_iteration_0 = True + assert len(context.messages) == messages_before + 1 + + return None + + register_after_llm_call_hook(after_hook) + + try: + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + allow_delegation=False, + max_iter=3, + ) + + task = Task( + description="Count to 3, taking your time", + expected_output="A count", + agent=agent, + ) + + result = agent.execute_task(task) + + assert hook_call_count > 0, "after_llm_call hook should have been called" + assert messages_added_in_iteration_0, "Message should have been added in iteration 0" + + executor_messages = agent.agent_executor.messages + message_contents = [msg.get("content", "") for msg in executor_messages if isinstance(msg, dict)] + assert any(test_message_content in content for content in message_contents), ( + f"Message added by hook in iteration 0 should be present in executor messages. " + f"Messages: {message_contents}" + ) + + assert len(executor_messages) > 2, "Executor should have more than initial messages" + assert result is not None + finally: + pass + + +@pytest.mark.vcr(filter_headers=["authorization"]) +def test_after_llm_call_hook_modifies_messages(): + """Test that after_llm_call hooks can modify messages for next iteration.""" + from crewai.utilities.llm_call_hooks import LLMCallHookContext, register_after_llm_call_hook + + hook_called = False + messages_before_hook = 0 + + def after_hook(context: LLMCallHookContext) -> str | None: + nonlocal hook_called, messages_before_hook + hook_called = True + messages_before_hook = len(context.messages) + context.messages.append({ + "role": "user", + "content": "Remember: This is iteration 2 context." + }) + return None # Don't modify response + + register_after_llm_call_hook(after_hook) + + try: + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + allow_delegation=False, + max_iter=2, + ) + + task = Task( + description="Count to 2", + expected_output="A count", + agent=agent, + ) + + result = agent.execute_task(task) + + assert hook_called, "after_llm_call hook should have been called" + assert len(agent.agent_executor.messages) > messages_before_hook + assert result is not None + finally: + pass + + +@pytest.mark.vcr(filter_headers=["authorization"]) +def test_llm_call_hooks_with_crew(): + """Test that LLM call hooks work with crew execution.""" + from crewai.utilities.llm_call_hooks import ( + LLMCallHookContext, + register_after_llm_call_hook, + register_before_llm_call_hook, + ) + + before_hook_called = False + after_hook_called = False + + def before_hook(context: LLMCallHookContext) -> None: + nonlocal before_hook_called + before_hook_called = True + assert context.executor is not None + assert context.agent is not None + assert context.task is not None + context.messages.append({ + "role": "system", + "content": "Additional system context from hook." + }) + + def after_hook(context: LLMCallHookContext) -> str | None: + nonlocal after_hook_called + after_hook_called = True + assert context.response is not None + assert len(context.messages) > 0 + return None + + register_before_llm_call_hook(before_hook) + register_after_llm_call_hook(after_hook) + + try: + agent = Agent( + role="Researcher", + goal="Research topics", + backstory="You are a researcher", + allow_delegation=False, + ) + + task = Task( + description="Research AI frameworks", + expected_output="A research summary", + agent=agent, + ) + + crew = Crew(agents=[agent], tasks=[task]) + result = crew.kickoff() + + assert before_hook_called, "before_llm_call hook should have been called" + assert after_hook_called, "after_llm_call hook should have been called" + assert result is not None + assert result.raw is not None + finally: + pass + + +@pytest.mark.vcr(filter_headers=["authorization"]) +def test_llm_call_hooks_can_modify_executor_attributes(): + """Test that hooks can access and modify executor attributes like tools.""" + from crewai.utilities.llm_call_hooks import LLMCallHookContext, register_before_llm_call_hook + from crewai.tools import tool + + @tool + def test_tool() -> str: + """A test tool.""" + return "test result" + + hook_called = False + original_tools_count = 0 + + def before_hook(context: LLMCallHookContext) -> None: + nonlocal hook_called, original_tools_count + hook_called = True + original_tools_count = len(context.executor.tools) + assert context.executor.max_iter > 0 + assert context.executor.iterations >= 0 + assert context.executor.tools is not None + + register_before_llm_call_hook(before_hook) + + try: + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + tools=[test_tool], + allow_delegation=False, + ) + + task = Task( + description="Use the test tool", + expected_output="Tool result", + agent=agent, + ) + + result = agent.execute_task(task) + + assert hook_called, "before_llm_call hook should have been called" + assert original_tools_count >= 0 + assert result is not None + finally: + pass + + +@pytest.mark.vcr(filter_headers=["authorization"]) +def test_llm_call_hooks_error_handling(): + """Test that hook errors don't break execution.""" + from crewai.utilities.llm_call_hooks import LLMCallHookContext, register_before_llm_call_hook + + hook_called = False + + def error_hook(context: LLMCallHookContext) -> None: + nonlocal hook_called + hook_called = True + raise ValueError("Test hook error") + + register_before_llm_call_hook(error_hook) + + try: + agent = Agent( + role="Test Agent", + goal="Test goal", + backstory="Test backstory", + allow_delegation=False, + ) + + task = Task( + description="Say hello", + expected_output="A greeting", + agent=agent, + ) + + result = agent.execute_task(task) + + assert hook_called, "before_llm_call hook should have been called" + assert result is not None + finally: + pass diff --git a/lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages.yaml b/lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages.yaml new file mode 100644 index 000000000..de4aa44bf --- /dev/null +++ b/lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages.yaml @@ -0,0 +1,126 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal\nTo give my best complete final answer to the task + respond using the exact following format:\n\nThought: I now can give a great + answer\nFinal Answer: Your final answer must be the great and the most complete + as possible, it must be outcome described.\n\nI MUST use these formats, my job + depends on it!"},{"role":"user","content":"\nCurrent Task: Count to 2\n\nThis + is the expected criteria for your final answer: A count\nyou MUST return the + actual complete content as the final answer, not a summary.\n\nBegin! This is + VERY important to you, use the tools available and give your best Final Answer, + your job depends on it!\n\nThought:"},{"role":"user","content":"Additional context: + This is a test modification."}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '849' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFJNb5wwEL3zK0Y+QwSI7LLcokqVcujHoR9S2wg5ZsBujceyTdIo2v9e + GTYLaROpFyTmzXt+b2YeEwCmOtYAE5IHMVqdveH09UHKLx+/2eFzkAdZXL8XJPr9h3dFydLIoNuf + KMIT60LQaDUGRWaBhUMeMKoW+11ZH/K8rmZgpA51pA02ZNVFkY3KqKzMy8ssr7KiOtElKYGeNfA9 + AQB4nL/RqOnwN2sgT58qI3rPB2TNuQmAOdKxwrj3ygduAktXUJAJaGbvnyRNgwwNXIOhexDcwKDu + EDgMMQBw4+/R/TBvleEarua/BooUyq2gw37yPKYyk9YbgBtDgcepzFFuTsjxbF7TYB3d+r+orFdG + edk65J5MNOoDWTajxwTgZh7S9Cw3s45GG9pAv3B+rtgdFj22LmeD1icwUOB6W9+nL+i1HQautN+M + mQkuJHYrdd0JnzpFGyDZpP7XzUvaS3Jlhv+RXwEh0AbsWuuwU+J54rXNYbzd19rOU54NM4/uTgls + g0IXN9Fhzye9HBTzDz7g2PbKDOisU8tV9batRFlfFn29K1lyTP4AAAD//wMApumqgWQDAAA= + headers: + CF-RAY: + - 99d044543db94e48-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:41:25 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=KLlCOQ_zxXquDvj96O28ObVFEoAbFE8R7zlmuiuXH1M-1762890085-1.0.1.1-UChItG1GnLDHrErY60dUpkbD3lEkSvfkTQpOmEtzd0fjjm_y1pJQiB.VDXVi2pPIMSelir0ZgiVXSh5.hGPb3RjQqbH3pv0Rr_2dQ59OIQ8; + path=/; expires=Tue, 11-Nov-25 20:11:25 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=u.Z6xV9tQd3ucK35BinKtlCkewcI6q_uQicyeEeeR18-1762890085355-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '559' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '735' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999817' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999817' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_bcaa0f8500714ed09f967488b238ce2e + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages_for_next_iteration.yaml b/lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages_for_next_iteration.yaml new file mode 100644 index 000000000..cf47c1930 --- /dev/null +++ b/lib/crewai/tests/cassettes/test_after_llm_call_hook_modifies_messages_for_next_iteration.yaml @@ -0,0 +1,222 @@ +interactions: +- request: + body: '{"trace_id": "aeb82647-004a-4a30-9481-d55f476d5659", "execution_type": + "crew", "user_identifier": null, "execution_context": {"crew_fingerprint": null, + "crew_name": "Unknown Crew", "flow_name": null, "crewai_version": "1.4.1", "privacy_level": + "standard"}, "execution_metadata": {"expected_duration_estimate": 300, "agent_count": + 0, "task_count": 0, "flow_method_count": 0, "execution_started_at": "2025-11-11T19:45:17.648657+00:00"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '434' + Content-Type: + - application/json + User-Agent: + - CrewAI-CLI/1.4.1 + X-Crewai-Version: + - 1.4.1 + method: POST + uri: https://app.crewai.com/crewai_plus/api/v1/tracing/batches + response: + body: + string: '{"error":"bad_credentials","message":"Bad credentials"}' + headers: + Connection: + - keep-alive + Content-Length: + - '55' + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 11 Nov 2025 19:45:17 GMT + cache-control: + - no-store + content-security-policy: + - 'default-src ''self'' *.app.crewai.com app.crewai.com; script-src ''self'' + ''unsafe-inline'' *.app.crewai.com app.crewai.com https://cdn.jsdelivr.net/npm/apexcharts + https://www.gstatic.com https://run.pstmn.io https://apis.google.com https://apis.google.com/js/api.js + https://accounts.google.com https://accounts.google.com/gsi/client https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css.map + https://*.google.com https://docs.google.com https://slides.google.com https://js.hs-scripts.com + https://js.sentry-cdn.com https://browser.sentry-cdn.com https://www.googletagmanager.com + https://js-na1.hs-scripts.com https://js.hubspot.com http://js-na1.hs-scripts.com + https://bat.bing.com https://cdn.amplitude.com https://cdn.segment.com https://d1d3n03t5zntha.cloudfront.net/ + https://descriptusercontent.com https://edge.fullstory.com https://googleads.g.doubleclick.net + https://js.hs-analytics.net https://js.hs-banner.com https://js.hsadspixel.net + https://js.hscollectedforms.net https://js.usemessages.com https://snap.licdn.com + https://static.cloudflareinsights.com https://static.reo.dev https://www.google-analytics.com + https://share.descript.com/; style-src ''self'' ''unsafe-inline'' *.app.crewai.com + app.crewai.com https://cdn.jsdelivr.net/npm/apexcharts; img-src ''self'' data: + *.app.crewai.com app.crewai.com https://zeus.tools.crewai.com https://dashboard.tools.crewai.com + https://cdn.jsdelivr.net https://forms.hsforms.com https://track.hubspot.com + https://px.ads.linkedin.com https://px4.ads.linkedin.com https://www.google.com + https://www.google.com.br; font-src ''self'' data: *.app.crewai.com app.crewai.com; + connect-src ''self'' *.app.crewai.com app.crewai.com https://zeus.tools.crewai.com + https://connect.useparagon.com/ https://zeus.useparagon.com/* https://*.useparagon.com/* + https://run.pstmn.io https://connect.tools.crewai.com/ https://*.sentry.io + https://www.google-analytics.com https://edge.fullstory.com https://rs.fullstory.com + https://api.hubspot.com https://forms.hscollectedforms.net https://api.hubapi.com + https://px.ads.linkedin.com https://px4.ads.linkedin.com https://google.com/pagead/form-data/16713662509 + https://google.com/ccm/form-data/16713662509 https://www.google.com/ccm/collect + https://worker-actionkit.tools.crewai.com https://api.reo.dev; frame-src ''self'' + *.app.crewai.com app.crewai.com https://connect.useparagon.com/ https://zeus.tools.crewai.com + https://zeus.useparagon.com/* https://connect.tools.crewai.com/ https://docs.google.com + https://drive.google.com https://slides.google.com https://accounts.google.com + https://*.google.com https://app.hubspot.com/ https://td.doubleclick.net https://www.googletagmanager.com/ + https://www.youtube.com https://share.descript.com' + expires: + - '0' + permissions-policy: + - camera=(), microphone=(self), geolocation=() + pragma: + - no-cache + referrer-policy: + - strict-origin-when-cross-origin + strict-transport-security: + - max-age=63072000; includeSubDomains + vary: + - Accept + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-permitted-cross-domain-policies: + - none + x-request-id: + - 48a89b0d-206b-4c1b-aa0d-ecc3b4ab525c + x-runtime: + - '0.088251' + x-xss-protection: + - 1; mode=block + status: + code: 401 + message: Unauthorized +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal\nTo give my best complete final answer to the task + respond using the exact following format:\n\nThought: I now can give a great + answer\nFinal Answer: Your final answer must be the great and the most complete + as possible, it must be outcome described.\n\nI MUST use these formats, my job + depends on it!"},{"role":"user","content":"\nCurrent Task: Count to 3, taking + your time\n\nThis is the expected criteria for your final answer: A count\nyou + MUST return the actual complete content as the final answer, not a summary.\n\nBegin! + This is VERY important to you, use the tools available and give your best Final + Answer, your job depends on it!\n\nThought:"}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '790' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFJNa9wwEL37Vww6r43tOpuNb2nKQgslOSy0NA1mIo9tdWVJSHK2Jex/ + L/J+2Ns20IuE5s0bzXszrxEAEzUrgfEOPe+NjO9Q41atP3/79GG7vX8QD0Xq15svX9/fUd+yRWDo + 5x/E/YmVcN0bSV5odYC5JfQUqmbXy3x1k77LViPQ65pkoLXGx0WSxb1QIs7T/CpOizgrjvROC06O + lfAYAQC8jmdoVNX0k5WQLk6RnpzDllh5TgJgVssQYeiccB6VZ4sJ5Fp5UmPvm04PbedL+AhK74Cj + gla8ECC0QQCgcjuy39VaKJRwO75KuFeUJAlsdnq8OkuUzD+w1AwOg0o1SDkDUCntMbg0Sns6Ivuz + GKlbY/Wz+4PKGqGE6ypL6LQKjTuvDRvRfQTwNJo2XPjAjNW98ZXXWxq/y5ZH09g0rBl6cwS99ihn + 8esTcFGvqsmjkG5mO+PIO6on6jQjHGqhZ0A0U/13N/+qfVAuVPs/5SeAczKe6spYqgW/VDylWQq7 + /Fba2eWxYebIvghOlRdkwyRqanCQhwVj7pfz1FeNUC1ZY8VhyxpTFTxfXWXNapmzaB/9BgAA//8D + AL0LXHV0AwAA + headers: + CF-RAY: + - 99d04a06dc4d1949-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:45:18 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=KnsnYxgmlpoHf.5TWnNgU30xb2tc0gK7SC2BbUkud2M-1762890318-1.0.1.1-3KeaQY59x5mY6n8DINELLaH9_b68w7W4ZZ0KeOknBHmQyDwx5qbtDonfYxOjsO_KykjtJLHpB0bsINSNEa9TrjNQHqUWTlRhldfTLenUG44; + path=/; expires=Tue, 11-Nov-25 20:15:18 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=ekC35NRP79GCMP.eTi_odl5.6DIsAeFEXKlanWUZOH4-1762890318589-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '598' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '632' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999827' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999827' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_cb36cbe6c33b42a28675e8c6d9a36fe9 + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/test_before_llm_call_hook_modifies_messages.yaml b/lib/crewai/tests/cassettes/test_before_llm_call_hook_modifies_messages.yaml new file mode 100644 index 000000000..ac13d92ca --- /dev/null +++ b/lib/crewai/tests/cassettes/test_before_llm_call_hook_modifies_messages.yaml @@ -0,0 +1,127 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal\nTo give my best complete final answer to the task + respond using the exact following format:\n\nThought: I now can give a great + answer\nFinal Answer: Your final answer must be the great and the most complete + as possible, it must be outcome described.\n\nI MUST use these formats, my job + depends on it!"},{"role":"user","content":"\nCurrent Task: Say hello\n\nThis + is the expected criteria for your final answer: A greeting\nyou MUST return + the actual complete content as the final answer, not a summary.\n\nBegin! This + is VERY important to you, use the tools available and give your best Final Answer, + your job depends on it!\n\nThought:"},{"role":"user","content":"Additional context: + This is a test modification."}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '851' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFJdi9swEHz3r9jqOT5sk+RSvx2lJW1poXDQ0vYwirS21cpaIclJr0f+ + +yE7F/s+Cn0xeGdnNLO7dwkAU5KVwETLg+isTt9w+rr/YESx27+93RaHVm4/ff7y8Vpcffv+ly0i + g3a/UIQH1oWgzmoMiswIC4c8YFTNL9fF5nWWbfIB6EiijrTGhnR5kaedMiotsmKVZss0X57oLSmB + npXwIwEAuBu+0aiR+IeVkC0eKh16zxtk5bkJgDnSscK498oHbgJbTKAgE9AM3q9b6ps2lPAeDB1A + cAON2iNwaGIA4MYf0P0075ThGq6GvxK2qDW9mks6rHvPYy7Taz0DuDEUeJzLEObmhBzP9jU11tHO + P6GyWhnl28oh92SiVR/IsgE9JgA3w5j6R8mZddTZUAX6jcNz+fpy1GPTembo6gQGClzP6pti8YJe + JTFwpf1s0Exw0aKcqNNWeC8VzYBklvq5m5e0x+TKNP8jPwFCoA0oK+tQKvE48dTmMF7vv9rOUx4M + M49urwRWQaGLm5BY816PJ8X8rQ/YVbUyDTrr1HhXta2Wotis8nqzLlhyTO4BAAD//wMAuV0QSWYD + AAA= + headers: + CF-RAY: + - 99d044428f103c35-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:41:22 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=jp.mByP87tLw_KZOIh7lXZ9UMACecreCMNwHwtJmUvQ-1762890082-1.0.1.1-D76UWkvWlN8e0zlQpgSlSHjrhx3Rkh_r8bz4XKx8kljJt8s9Okre9bo7M62ewJNFK9O9iuHkADMKeAEwlsc4Hg0MsF2vt2Hu1J0xikSInv0; + path=/; expires=Tue, 11-Nov-25 20:11:22 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=pzTqogdMFPJY2.Yrj49LODdUKbD8UBctCWNyIZVsvK4-1762890082258-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '460' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '478' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999817' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999820' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_3bda51e6d3e34f8cadcc12551dc29ab0 + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/test_llm_call_hooks_can_modify_executor_attributes.yaml b/lib/crewai/tests/cassettes/test_llm_call_hooks_can_modify_executor_attributes.yaml new file mode 100644 index 000000000..6e56ea1db --- /dev/null +++ b/lib/crewai/tests/cassettes/test_llm_call_hooks_can_modify_executor_attributes.yaml @@ -0,0 +1,262 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal\nYou ONLY have access to the following tools, and + should NEVER make up tools that are not listed here:\n\nTool Name: test_tool\nTool + Arguments: {}\nTool Description: A test tool.\n\nIMPORTANT: Use the following + format in your response:\n\n```\nThought: you should always think about what + to do\nAction: the action to take, only one name of [test_tool], just the name, + exactly as it''s written.\nAction Input: the input to the action, just a simple + JSON object, enclosed in curly braces, using \" to wrap keys and values.\nObservation: + the result of the action\n```\n\nOnce all necessary information is gathered, + return the following format:\n\n```\nThought: I now know the final answer\nFinal + Answer: the final answer to the original input question\n```"},{"role":"user","content":"\nCurrent + Task: Use the test tool\n\nThis is the expected criteria for your final answer: + Tool result\nyou MUST return the actual complete content as the final answer, + not a summary.\n\nBegin! This is VERY important to you, use the tools available + and give your best Final Answer, your job depends on it!\n\nThought:"},{"role":"user","content":"Additional + context: This is a test modification."}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1311' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAA4xTy47bMAy85ysIneMgcbNp1reizwXaXrpAD83CVmTaViqLWolu2gb590LOw94+ + gF504HBGw6F0mAAIXYoMhGokq9aZ5KWkz/vdTn14i/dv9h9f19tXi3dtun789H71U0wjg7Y7VHxh + zRS1ziBrsidYeZSMUXXxfJWub+fzddoDLZVoIq12nCxni6TVVifpPL1J5stksTzTG9IKg8jgywQA + 4NCf0agt8bvIYD69VFoMQdYosmsTgPBkYkXIEHRgaVlMB1CRZbS996IoNva+oa5uOIM7CA11poQu + IHCDwBg4ZyIDTFAj90WPj532WIK2FflWxqGhIt+DlbbSgLRhj362sS9URLNB6FKCO+s6zuBw3Nii + KMb2PFZdkDEj2xkzAqS1xP11fTAPZ+R4jcJQ7Txtw29UUWmrQ5N7lIFsHDswOdGjxwnAQx959yRF + 4Ty1Lnr+iv116Wp10hPDqgf02XkfgomlGbFuL6wnenmJLLUJo6UJJVWD5UAdNiy7UtMImIym/tPN + 37RPk2tb/4/8ACiFjrHMncdSq6cTD20e40/4V9s15d6wCOi/aYU5a/RxEyVWsjOn5ynCj8DY5pW2 + NXrn9emNVi5fqnR9s6jWq1RMjpNfAAAA//8DANALR4WyAwAA + headers: + CF-RAY: + - 99d044470bdeb976-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:41:23 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=p01_b1BsQgwR2woMBWf1E0gJMDDl7pvqkEVHpHAsMJA-1762890083-1.0.1.1-u8iYLTTx0lmfSR1.CzuuYiHgt03yVVUMsBD8WgExXWm7ts.grUwM1ifj9p6xIz.HElrnQdfDSBD5Lv045aNr61YcB8WW3Vz33W9N0Gn0P3w; + path=/; expires=Tue, 11-Nov-25 20:11:23 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=2gUmBgxb3VydVYt8.t_P6bY8U_pS.a4KeYpZWDDYM9Q-1762890083295-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '729' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '759' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999707' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999707' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_70c7033dbc5e4ced80d3fdcbcda2c675 + status: + code: 200 + message: OK +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal\nYou ONLY have access to the following tools, and + should NEVER make up tools that are not listed here:\n\nTool Name: test_tool\nTool + Arguments: {}\nTool Description: A test tool.\n\nIMPORTANT: Use the following + format in your response:\n\n```\nThought: you should always think about what + to do\nAction: the action to take, only one name of [test_tool], just the name, + exactly as it''s written.\nAction Input: the input to the action, just a simple + JSON object, enclosed in curly braces, using \" to wrap keys and values.\nObservation: + the result of the action\n```\n\nOnce all necessary information is gathered, + return the following format:\n\n```\nThought: I now know the final answer\nFinal + Answer: the final answer to the original input question\n```"},{"role":"user","content":"\nCurrent + Task: Use the test tool\n\nThis is the expected criteria for your final answer: + Tool result\nyou MUST return the actual complete content as the final answer, + not a summary.\n\nBegin! This is VERY important to you, use the tools available + and give your best Final Answer, your job depends on it!\n\nThought:"},{"role":"user","content":"Additional + context: This is a test modification."},{"role":"assistant","content":"```\nThought: + I should use the test_tool to get the required information for the final answer.\nAction: + test_tool\nAction Input: {}\n```\nObservation: test result"},{"role":"user","content":"Additional + context: This is a test modification."}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1584' + content-type: + - application/json + cookie: + - __cf_bm=p01_b1BsQgwR2woMBWf1E0gJMDDl7pvqkEVHpHAsMJA-1762890083-1.0.1.1-u8iYLTTx0lmfSR1.CzuuYiHgt03yVVUMsBD8WgExXWm7ts.grUwM1ifj9p6xIz.HElrnQdfDSBD5Lv045aNr61YcB8WW3Vz33W9N0Gn0P3w; + _cfuvid=2gUmBgxb3VydVYt8.t_P6bY8U_pS.a4KeYpZWDDYM9Q-1762890083295-0.0.1.1-604800000 + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFLBbtQwEL3nKyyfN1WS3S5pbhRRKCeEkCpgq8RrTxJTxzb2pC1U++/I + TrtJoUhcLNlv3vN7M/OQEEKloBWhvGfIB6vSN8xc3b+/FG/P3rX8x9X+y8dfHy7O8fYra88/0VVg + mP134PjEOuFmsApQGj3B3AFDCKr5q21RnmVZuY7AYASoQOssppuTPB2klmmRFadptknzzSO9N5KD + pxX5lhBCyEM8g1Et4J5WJFs9vQzgPeuAVsciQqgzKrxQ5r30yDTS1QxyoxF09N40zU5/7s3Y9ViR + S6LNHbkJB/ZAWqmZIkz7O3A7fRFvr+OtIggeiQM/KtzppmmW+g7a0bMQUo9KLQCmtUEWmhSTXT8i + h2MWZTrrzN7/QaWt1NL3tQPmjQ6+PRpLI3pICLmOPRuftYFaZwaLNZobiN+t83LSo/OsZvQIokGm + Fqz1dvWCXi0AmVR+0XXKGe9BzNR5RGwU0iyAZJH6bzcvaU/Jpe7+R34GOAeLIGrrQEj+PPFc5iCs + 8r/Kjl2OhqkHdys51CjBhUkIaNmopv2i/qdHGOpW6g6cdXJastbWG16Up3lbbguaHJLfAAAA//8D + AJW0fwtzAwAA + headers: + CF-RAY: + - 99d0444cbd6db976-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:41:23 GMT + Server: + - cloudflare + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '527' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '578' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999655' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999655' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_6b1d84dcdde643cea5160e155ee624db + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/test_llm_call_hooks_error_handling.yaml b/lib/crewai/tests/cassettes/test_llm_call_hooks_error_handling.yaml new file mode 100644 index 000000000..1a77ae4a5 --- /dev/null +++ b/lib/crewai/tests/cassettes/test_llm_call_hooks_error_handling.yaml @@ -0,0 +1,159 @@ +interactions: +- request: + body: '{"name":"llama3.2:3b"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '22' + content-type: + - application/json + host: + - localhost:11434 + user-agent: + - litellm/1.78.5 + method: POST + uri: http://localhost:11434/api/show + response: + body: + string: '{"error":"model ''llama3.2:3b'' not found"}' + headers: + Content-Length: + - '41' + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 11 Nov 2025 19:41:28 GMT + status: + code: 404 + message: Not Found +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal\nTo give my best complete final answer to the task + respond using the exact following format:\n\nThought: I now can give a great + answer\nFinal Answer: Your final answer must be the great and the most complete + as possible, it must be outcome described.\n\nI MUST use these formats, my job + depends on it!"},{"role":"user","content":"\nCurrent Task: Say hello\n\nThis + is the expected criteria for your final answer: A greeting\nyou MUST return + the actual complete content as the final answer, not a summary.\n\nBegin! This + is VERY important to you, use the tools available and give your best Final Answer, + your job depends on it!\n\nThought:"},{"role":"user","content":"Additional context: + This is a test modification."}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '851' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + H4sIAAAAAAAAAwAAAP//jFLRbtQwEHzPVyx+vlRJmrte84KOSqgFCSFAqLRUkc/ZJAbHa9lOy6m6 + f0dOrpe0gMRLpHh2Znd29jECYLJiBTDRci86o+ILTten/ccPFzyp398srz9/2/o3X/PN6btN84kt + AoO2P1D4J9aJoM4o9JL0CAuL3GNQTc9W2fo8SdbnA9BRhSrQGuPj/CSNO6llnCXZMk7yOM0P9Jak + QMcKuI0AAB6HbxhUV/iLFZAsnl46dI43yIpjEQCzpMIL485J57n2bDGBgrRHPcz+paW+aX0BV6Dp + AQTX0Mh7BA5NMABcuwe03/VbqbmCzfBXwCUqRa/g8sC4grEN7KgHTxXfvZ63s1j3jgfPuldqBnCt + yfOws8Ho3QHZH60paoylrXtBZbXU0rWlRe5IBxvOk2EDuo8A7oYV9s+2woylzvjS008c2qWrs1GP + TdFNaJYdQE+eqxlrTPGlXlmh51K5WQhMcNFiNVGnxHhfSZoB0cz1n9P8TXt0LnXzP/ITIAQaj1Vp + LFZSPHc8lVkMl/2vsuOWh4GZQ3svBZZeog1JVFjzXo3nxtzOeezKWuoGrbFyvLnalLnI1su0Xq8y + Fu2j3wAAAP//AwDurzwzggMAAA== + headers: + CF-RAY: + - 99d0446e698367ab-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:41:30 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=b52crfzdOm5rh4aOc2LfM8aQKFI.ZL9WCZXaPBDdG5k-1762890090-1.0.1.1-T2xhtwX0vuEnMIb8NRgP4w3RRn1N1ZwSjuhKBob1vDLDmN7XhCKkoIg3IrlC9KEyhA65IGa5DWsHfmlRKKxqw6sIPA98BSO6E3wsTRspHw4; + path=/; expires=Tue, 11-Nov-25 20:11:30 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=0TH0Kjp_5t6yhwXKA1wlKBHaczp.TeWhM2A5t6by1sI-1762890090153-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '1049' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '1387' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999817' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999817' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_4b132b998ed941b5b6a85ddbb36e2b65 + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/test_llm_call_hooks_with_crew.yaml b/lib/crewai/tests/cassettes/test_llm_call_hooks_with_crew.yaml new file mode 100644 index 000000000..fe5cc5867 --- /dev/null +++ b/lib/crewai/tests/cassettes/test_llm_call_hooks_with_crew.yaml @@ -0,0 +1,182 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Researcher. You are a + researcher\nYour personal goal is: Research topics\nTo give my best complete + final answer to the task respond using the exact following format:\n\nThought: + I now can give a great answer\nFinal Answer: Your final answer must be the great + and the most complete as possible, it must be outcome described.\n\nI MUST use + these formats, my job depends on it!"},{"role":"user","content":"\nCurrent Task: + Research AI frameworks\n\nThis is the expected criteria for your final answer: + A research summary\nyou MUST return the actual complete content as the final + answer, not a summary.\n\nYou MUST follow these instructions: \n - Include specific + examples and real-world case studies to enhance the credibility and depth of + the article ideas.\n - Incorporate mentions of notable companies, projects, + or tools relevant to each topic to provide concrete context.\n - Add diverse + viewpoints such as interviews with experts, users, or thought leaders to enrich + the narrative and lend authority.\n - Address ethical, social, and emotional + considerations explicitly to reflect a balanced and comprehensive analysis.\n + - Enhance the descriptions by including implications for future developments + and the potential impact on society.\n - Use more engaging and vivid language + that draws the reader into each topic''s nuances and importance.\n - Include + notes or summaries that contextualize each set of ideas in terms of relevance + and potential reader engagement.\n - In future tasks, focus on elaborating initial + outlines into more detailed and nuanced article proposals with richer content + and insights.\n\nBegin! This is VERY important to you, use the tools available + and give your best Final Answer, your job depends on it!\n\nThought:"},{"role":"user","content":"Additional + context: This is a test modification."}],"model":"gpt-4.1-mini"}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1894' + content-type: + - application/json + host: + - api.openai.com + user-agent: + - OpenAI/Python 1.109.1 + x-stainless-arch: + - arm64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 1.109.1 + x-stainless-read-timeout: + - '600' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: !!binary | + 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 + headers: + CF-RAY: + - 99d0447958ce36e8-SJC + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Type: + - application/json + Date: + - Tue, 11 Nov 2025 19:41:45 GMT + Server: + - cloudflare + Set-Cookie: + - __cf_bm=dSe1gQEFfPpE4AOsFi3S3RQkzPCQnV1.Ywe__K7cSSU-1762890105-1.0.1.1-I1CSTO8ri4tjbaHdIHQ9YP9c2pa.y9WwMQFRaUztT95T_OAe5V0ndTFN4pO1RiCXh15TUpWmBxRdxIWjcYDMqrDIvKWInLO5aavGFWZ1rys; + path=/; expires=Tue, 11-Nov-25 20:11:45 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=LMf_4EPFZGfTiqcjmjEk7WxOTuX2ukd3Cs_R8170wJ4-1762890105804-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + openai-organization: + - crewai-iuxna1 + openai-processing-ms: + - '15065' + openai-project: + - proj_xitITlrFeen7zjNSzML82h9x + openai-version: + - '2020-10-01' + x-envoy-upstream-service-time: + - '15254' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-project-tokens: + - '150000000' + x-ratelimit-limit-requests: + - '30000' + x-ratelimit-limit-tokens: + - '150000000' + x-ratelimit-remaining-project-tokens: + - '149999560' + x-ratelimit-remaining-requests: + - '29999' + x-ratelimit-remaining-tokens: + - '149999560' + x-ratelimit-reset-project-tokens: + - 0s + x-ratelimit-reset-requests: + - 2ms + x-ratelimit-reset-tokens: + - 0s + x-request-id: + - req_c49c9fba20ff4f05903eff3c78797ce1 + status: + code: 200 + message: OK +version: 1 From fbe4aa4bd143597984f208406d23db63e703fc2d Mon Sep 17 00:00:00 2001 From: Heitor Carvalho Date: Wed, 12 Nov 2025 15:28:00 -0300 Subject: [PATCH 3/3] feat: fetch and store more data about okta authorization server (#3894) --- .../src/crewai/cli/authentication/main.py | 28 +++- .../authentication/providers/base_provider.py | 4 + .../cli/authentication/providers/okta.py | 21 ++- lib/crewai/src/crewai/cli/command.py | 6 +- lib/crewai/src/crewai/cli/config.py | 8 +- lib/crewai/src/crewai/cli/enterprise/main.py | 58 +++++-- lib/crewai/src/crewai/cli/git.py | 2 +- lib/crewai/src/crewai/cli/plus_api.py | 35 ++-- lib/crewai/src/crewai/cli/settings/main.py | 2 +- lib/crewai/src/crewai/cli/tools/main.py | 12 +- lib/crewai/src/crewai/cli/utils.py | 67 ++++---- .../cli/authentication/providers/test_okta.py | 155 ++++++++++++++++++ lib/crewai/tests/cli/enterprise/test_main.py | 8 +- 13 files changed, 323 insertions(+), 83 deletions(-) diff --git a/lib/crewai/src/crewai/cli/authentication/main.py b/lib/crewai/src/crewai/cli/authentication/main.py index b23fe9114..7bda8fe08 100644 --- a/lib/crewai/src/crewai/cli/authentication/main.py +++ b/lib/crewai/src/crewai/cli/authentication/main.py @@ -1,5 +1,5 @@ import time -from typing import Any +from typing import TYPE_CHECKING, Any, TypeVar, cast import webbrowser from pydantic import BaseModel, Field @@ -13,6 +13,8 @@ from crewai.cli.shared.token_manager import TokenManager console = Console() +TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings") + class Oauth2Settings(BaseModel): provider: str = Field( @@ -28,9 +30,15 @@ class Oauth2Settings(BaseModel): description="OAuth2 audience value, typically used to identify the target API or resource.", default=None, ) + extra: dict[str, Any] = Field( + description="Extra configuration for the OAuth2 provider.", + default={}, + ) @classmethod - def from_settings(cls): + def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings: + """Create an Oauth2Settings instance from the CLI settings.""" + settings = Settings() return cls( @@ -38,12 +46,20 @@ class Oauth2Settings(BaseModel): domain=settings.oauth2_domain, client_id=settings.oauth2_client_id, audience=settings.oauth2_audience, + extra=settings.oauth2_extra, ) +if TYPE_CHECKING: + from crewai.cli.authentication.providers.base_provider import BaseProvider + + class ProviderFactory: @classmethod - def from_settings(cls, settings: Oauth2Settings | None = None): + def from_settings( + cls: type["ProviderFactory"], # noqa: UP037 + settings: Oauth2Settings | None = None, + ) -> "BaseProvider": # noqa: UP037 settings = settings or Oauth2Settings.from_settings() import importlib @@ -53,11 +69,11 @@ class ProviderFactory: ) provider = getattr(module, f"{settings.provider.capitalize()}Provider") - return provider(settings) + return cast("BaseProvider", provider(settings)) class AuthenticationCommand: - def __init__(self): + def __init__(self) -> None: self.token_manager = TokenManager() self.oauth2_provider = ProviderFactory.from_settings() @@ -84,7 +100,7 @@ class AuthenticationCommand: timeout=20, ) response.raise_for_status() - return response.json() + return cast(dict[str, Any], response.json()) def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None: """Display the authentication instructions to the user.""" diff --git a/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py b/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py index 2b7a0140e..0c8057b4d 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py +++ b/lib/crewai/src/crewai/cli/authentication/providers/base_provider.py @@ -24,3 +24,7 @@ class BaseProvider(ABC): @abstractmethod def get_client_id(self) -> str: ... + + def get_required_fields(self) -> list[str]: + """Returns which provider-specific fields inside the "extra" dict will be required""" + return [] diff --git a/lib/crewai/src/crewai/cli/authentication/providers/okta.py b/lib/crewai/src/crewai/cli/authentication/providers/okta.py index d13087e7d..90f5e2908 100644 --- a/lib/crewai/src/crewai/cli/authentication/providers/okta.py +++ b/lib/crewai/src/crewai/cli/authentication/providers/okta.py @@ -3,16 +3,16 @@ from crewai.cli.authentication.providers.base_provider import BaseProvider class OktaProvider(BaseProvider): def get_authorize_url(self) -> str: - return f"https://{self.settings.domain}/oauth2/default/v1/device/authorize" + return f"{self._oauth2_base_url()}/v1/device/authorize" def get_token_url(self) -> str: - return f"https://{self.settings.domain}/oauth2/default/v1/token" + return f"{self._oauth2_base_url()}/v1/token" def get_jwks_url(self) -> str: - return f"https://{self.settings.domain}/oauth2/default/v1/keys" + return f"{self._oauth2_base_url()}/v1/keys" def get_issuer(self) -> str: - return f"https://{self.settings.domain}/oauth2/default" + return self._oauth2_base_url().removesuffix("/oauth2") def get_audience(self) -> str: if self.settings.audience is None: @@ -27,3 +27,16 @@ class OktaProvider(BaseProvider): "Client ID is required. Please set it in the configuration." ) return self.settings.client_id + + def get_required_fields(self) -> list[str]: + return ["authorization_server_name", "using_org_auth_server"] + + def _oauth2_base_url(self) -> str: + using_org_auth_server = self.settings.extra.get("using_org_auth_server", False) + + if using_org_auth_server: + base_url = f"https://{self.settings.domain}/oauth2" + else: + base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}" + + return f"{base_url}" diff --git a/lib/crewai/src/crewai/cli/command.py b/lib/crewai/src/crewai/cli/command.py index e889b7125..3f85318fb 100644 --- a/lib/crewai/src/crewai/cli/command.py +++ b/lib/crewai/src/crewai/cli/command.py @@ -11,18 +11,18 @@ console = Console() class BaseCommand: - def __init__(self): + def __init__(self) -> None: self._telemetry = Telemetry() self._telemetry.set_tracer() class PlusAPIMixin: - def __init__(self, telemetry): + def __init__(self, telemetry: Telemetry) -> None: try: telemetry.set_tracer() self.plus_api_client = PlusAPI(api_key=get_auth_token()) except Exception: - self._deploy_signup_error_span = telemetry.deploy_signup_error_span() + telemetry.deploy_signup_error_span() console.print( "Please sign up/login to CrewAI+ before using the CLI.", style="bold red", diff --git a/lib/crewai/src/crewai/cli/config.py b/lib/crewai/src/crewai/cli/config.py index dea3691ae..7af9904e0 100644 --- a/lib/crewai/src/crewai/cli/config.py +++ b/lib/crewai/src/crewai/cli/config.py @@ -2,6 +2,7 @@ import json from logging import getLogger from pathlib import Path import tempfile +from typing import Any from pydantic import BaseModel, Field @@ -136,7 +137,12 @@ class Settings(BaseModel): default=DEFAULT_CLI_SETTINGS["oauth2_domain"], ) - def __init__(self, config_path: Path | None = None, **data): + oauth2_extra: dict[str, Any] = Field( + description="Extra configuration for the OAuth2 provider.", + default={}, + ) + + def __init__(self, config_path: Path | None = None, **data: dict[str, Any]) -> None: """Load Settings from config path with fallback support""" if config_path is None: config_path = get_writable_config_path() diff --git a/lib/crewai/src/crewai/cli/enterprise/main.py b/lib/crewai/src/crewai/cli/enterprise/main.py index 62002608e..2a73f1ae0 100644 --- a/lib/crewai/src/crewai/cli/enterprise/main.py +++ b/lib/crewai/src/crewai/cli/enterprise/main.py @@ -1,9 +1,10 @@ -from typing import Any +from typing import Any, cast import requests from requests.exceptions import JSONDecodeError, RequestException from rich.console import Console +from crewai.cli.authentication.main import Oauth2Settings, ProviderFactory from crewai.cli.command import BaseCommand from crewai.cli.settings.main import SettingsCommand from crewai.cli.version import get_crewai_version @@ -13,7 +14,7 @@ console = Console() class EnterpriseConfigureCommand(BaseCommand): - def __init__(self): + def __init__(self) -> None: super().__init__() self.settings_command = SettingsCommand() @@ -54,25 +55,12 @@ class EnterpriseConfigureCommand(BaseCommand): except JSONDecodeError as e: raise ValueError(f"Invalid JSON response from {oauth_endpoint}") from e - required_fields = [ - "audience", - "domain", - "device_authorization_client_id", - "provider", - ] - missing_fields = [ - field for field in required_fields if field not in oauth_config - ] - - if missing_fields: - raise ValueError( - f"Missing required fields in OAuth2 configuration: {', '.join(missing_fields)}" - ) + self._validate_oauth_config(oauth_config) console.print( "✅ Successfully retrieved OAuth2 configuration", style="green" ) - return oauth_config + return cast(dict[str, Any], oauth_config) except RequestException as e: raise ValueError(f"Failed to connect to enterprise URL: {e!s}") from e @@ -89,6 +77,7 @@ class EnterpriseConfigureCommand(BaseCommand): "oauth2_audience": oauth_config["audience"], "oauth2_client_id": oauth_config["device_authorization_client_id"], "oauth2_domain": oauth_config["domain"], + "oauth2_extra": oauth_config["extra"], } console.print("🔄 Updating local OAuth2 configuration...") @@ -99,3 +88,38 @@ class EnterpriseConfigureCommand(BaseCommand): except Exception as e: raise ValueError(f"Failed to update OAuth2 settings: {e!s}") from e + + def _validate_oauth_config(self, oauth_config: dict[str, Any]) -> None: + required_fields = [ + "audience", + "domain", + "device_authorization_client_id", + "provider", + "extra", + ] + + missing_basic_fields = [ + field for field in required_fields if field not in oauth_config + ] + missing_provider_specific_fields = [ + field + for field in self._get_provider_specific_fields(oauth_config["provider"]) + if field not in oauth_config.get("extra", {}) + ] + + if missing_basic_fields: + raise ValueError( + f"Missing required fields in OAuth2 configuration: [{', '.join(missing_basic_fields)}]" + ) + + if missing_provider_specific_fields: + raise ValueError( + f"Missing authentication provider required fields in OAuth2 configuration: [{', '.join(missing_provider_specific_fields)}] (Configured provider: '{oauth_config['provider']}')" + ) + + def _get_provider_specific_fields(self, provider_name: str) -> list[str]: + provider = ProviderFactory.from_settings( + Oauth2Settings(provider=provider_name, client_id="dummy", domain="dummy") + ) + + return provider.get_required_fields() diff --git a/lib/crewai/src/crewai/cli/git.py b/lib/crewai/src/crewai/cli/git.py index b493e88c0..fb08c391a 100644 --- a/lib/crewai/src/crewai/cli/git.py +++ b/lib/crewai/src/crewai/cli/git.py @@ -3,7 +3,7 @@ import subprocess class Repository: - def __init__(self, path="."): + def __init__(self, path: str = ".") -> None: self.path = path if not self.is_git_installed(): diff --git a/lib/crewai/src/crewai/cli/plus_api.py b/lib/crewai/src/crewai/cli/plus_api.py index 6121dd718..5d7141179 100644 --- a/lib/crewai/src/crewai/cli/plus_api.py +++ b/lib/crewai/src/crewai/cli/plus_api.py @@ -1,3 +1,4 @@ +from typing import Any from urllib.parse import urljoin import requests @@ -36,19 +37,21 @@ class PlusAPI: str(settings.enterprise_base_url) or DEFAULT_CREWAI_ENTERPRISE_URL ) - def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Response: + def _make_request( + self, method: str, endpoint: str, **kwargs: Any + ) -> requests.Response: url = urljoin(self.base_url, endpoint) session = requests.Session() session.trust_env = False return session.request(method, url, headers=self.headers, **kwargs) - def login_to_tool_repository(self): + def login_to_tool_repository(self) -> requests.Response: return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login") - def get_tool(self, handle: str): + def get_tool(self, handle: str) -> requests.Response: return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}") - def get_agent(self, handle: str): + def get_agent(self, handle: str) -> requests.Response: return self._make_request("GET", f"{self.AGENTS_RESOURCE}/{handle}") def publish_tool( @@ -58,8 +61,8 @@ class PlusAPI: version: str, description: str | None, encoded_file: str, - available_exports: list[str] | None = None, - ): + available_exports: list[dict[str, Any]] | None = None, + ) -> requests.Response: params = { "handle": handle, "public": is_public, @@ -111,13 +114,13 @@ class PlusAPI: def list_crews(self) -> requests.Response: return self._make_request("GET", self.CREWS_RESOURCE) - def create_crew(self, payload) -> requests.Response: + def create_crew(self, payload: dict[str, Any]) -> requests.Response: return self._make_request("POST", self.CREWS_RESOURCE, json=payload) def get_organizations(self) -> requests.Response: return self._make_request("GET", self.ORGANIZATIONS_RESOURCE) - def initialize_trace_batch(self, payload) -> requests.Response: + def initialize_trace_batch(self, payload: dict[str, Any]) -> requests.Response: return self._make_request( "POST", f"{self.TRACING_RESOURCE}/batches", @@ -125,14 +128,18 @@ class PlusAPI: timeout=30, ) - def initialize_ephemeral_trace_batch(self, payload) -> requests.Response: + def initialize_ephemeral_trace_batch( + self, payload: dict[str, Any] + ) -> requests.Response: return self._make_request( "POST", f"{self.EPHEMERAL_TRACING_RESOURCE}/batches", json=payload, ) - def send_trace_events(self, trace_batch_id: str, payload) -> requests.Response: + def send_trace_events( + self, trace_batch_id: str, payload: dict[str, Any] + ) -> requests.Response: return self._make_request( "POST", f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events", @@ -141,7 +148,7 @@ class PlusAPI: ) def send_ephemeral_trace_events( - self, trace_batch_id: str, payload + self, trace_batch_id: str, payload: dict[str, Any] ) -> requests.Response: return self._make_request( "POST", @@ -150,7 +157,9 @@ class PlusAPI: timeout=30, ) - def finalize_trace_batch(self, trace_batch_id: str, payload) -> requests.Response: + def finalize_trace_batch( + self, trace_batch_id: str, payload: dict[str, Any] + ) -> requests.Response: return self._make_request( "PATCH", f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize", @@ -159,7 +168,7 @@ class PlusAPI: ) def finalize_ephemeral_trace_batch( - self, trace_batch_id: str, payload + self, trace_batch_id: str, payload: dict[str, Any] ) -> requests.Response: return self._make_request( "PATCH", diff --git a/lib/crewai/src/crewai/cli/settings/main.py b/lib/crewai/src/crewai/cli/settings/main.py index 3fa4f2af0..83a50c2fe 100644 --- a/lib/crewai/src/crewai/cli/settings/main.py +++ b/lib/crewai/src/crewai/cli/settings/main.py @@ -34,7 +34,7 @@ class SettingsCommand(BaseCommand): current_value = getattr(self.settings, field_name) description = field_info.description or "No description available" display_value = ( - str(current_value) if current_value is not None else "Not set" + str(current_value) if current_value not in [None, {}] else "Not set" ) table.add_row(field_name, display_value, description) diff --git a/lib/crewai/src/crewai/cli/tools/main.py b/lib/crewai/src/crewai/cli/tools/main.py index 09bc927d3..2705388c5 100644 --- a/lib/crewai/src/crewai/cli/tools/main.py +++ b/lib/crewai/src/crewai/cli/tools/main.py @@ -30,11 +30,11 @@ class ToolCommand(BaseCommand, PlusAPIMixin): A class to handle tool repository related operations for CrewAI projects. """ - def __init__(self): + def __init__(self) -> None: BaseCommand.__init__(self) PlusAPIMixin.__init__(self, telemetry=self._telemetry) - def create(self, handle: str): + def create(self, handle: str) -> None: self._ensure_not_in_project() folder_name = handle.replace(" ", "_").replace("-", "_").lower() @@ -64,7 +64,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): finally: os.chdir(old_directory) - def publish(self, is_public: bool, force: bool = False): + def publish(self, is_public: bool, force: bool = False) -> None: if not git.Repository().is_synced() and not force: console.print( "[bold red]Failed to publish tool.[/bold red]\n" @@ -137,7 +137,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): style="bold green", ) - def install(self, handle: str): + def install(self, handle: str) -> None: self._print_current_organization() get_response = self.plus_api_client.get_tool(handle) @@ -180,7 +180,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): settings.org_name = login_response_json["current_organization"]["name"] settings.dump() - def _add_package(self, tool_details: dict[str, Any]): + def _add_package(self, tool_details: dict[str, Any]) -> None: is_from_pypi = tool_details.get("source", None) == "pypi" tool_handle = tool_details["handle"] repository_handle = tool_details["repository"]["handle"] @@ -209,7 +209,7 @@ class ToolCommand(BaseCommand, PlusAPIMixin): click.echo(add_package_result.stderr, err=True) raise SystemExit - def _ensure_not_in_project(self): + def _ensure_not_in_project(self) -> None: if os.path.isfile("./pyproject.toml"): console.print( "[bold red]Oops! It looks like you're inside a project.[/bold red]" diff --git a/lib/crewai/src/crewai/cli/utils.py b/lib/crewai/src/crewai/cli/utils.py index 041bc4e9d..b73f9f76b 100644 --- a/lib/crewai/src/crewai/cli/utils.py +++ b/lib/crewai/src/crewai/cli/utils.py @@ -5,7 +5,7 @@ import os from pathlib import Path import shutil import sys -from typing import Any, get_type_hints +from typing import Any, cast, get_type_hints import click from rich.console import Console @@ -23,7 +23,9 @@ if sys.version_info >= (3, 11): console = Console() -def copy_template(src, dst, name, class_name, folder_name): +def copy_template( + src: Path, dst: Path, name: str, class_name: str, folder_name: str +) -> None: """Copy a file from src to dst.""" with open(src, "r") as file: content = file.read() @@ -40,13 +42,13 @@ def copy_template(src, dst, name, class_name, folder_name): click.secho(f" - Created {dst}", fg="green") -def read_toml(file_path: str = "pyproject.toml"): +def read_toml(file_path: str = "pyproject.toml") -> dict[str, Any]: """Read the content of a TOML file and return it as a dictionary.""" with open(file_path, "rb") as f: return tomli.load(f) -def parse_toml(content): +def parse_toml(content: str) -> dict[str, Any]: if sys.version_info >= (3, 11): return tomllib.loads(content) return tomli.loads(content) @@ -103,7 +105,7 @@ def _get_project_attribute( ) except Exception as e: # Handle TOML decode errors for Python 3.11+ - if sys.version_info >= (3, 11) and isinstance(e, tomllib.TOMLDecodeError): # type: ignore + if sys.version_info >= (3, 11) and isinstance(e, tomllib.TOMLDecodeError): console.print( f"Error: {pyproject_path} is not a valid TOML file.", style="bold red" ) @@ -126,7 +128,7 @@ def _get_nested_value(data: dict[str, Any], keys: list[str]) -> Any: return reduce(dict.__getitem__, keys, data) -def fetch_and_json_env_file(env_file_path: str = ".env") -> dict: +def fetch_and_json_env_file(env_file_path: str = ".env") -> dict[str, Any]: """Fetch the environment variables from a .env file and return them as a dictionary.""" try: # Read the .env file @@ -150,7 +152,7 @@ def fetch_and_json_env_file(env_file_path: str = ".env") -> dict: return {} -def tree_copy(source, destination): +def tree_copy(source: Path, destination: Path) -> None: """Copies the entire directory structure from the source to the destination.""" for item in os.listdir(source): source_item = os.path.join(source, item) @@ -161,7 +163,7 @@ def tree_copy(source, destination): shutil.copy2(source_item, destination_item) -def tree_find_and_replace(directory, find, replace): +def tree_find_and_replace(directory: Path, find: str, replace: str) -> None: """Recursively searches through a directory, replacing a target string in both file contents and filenames with a specified replacement string. """ @@ -187,7 +189,7 @@ def tree_find_and_replace(directory, find, replace): os.rename(old_dirpath, new_dirpath) -def load_env_vars(folder_path): +def load_env_vars(folder_path: Path) -> dict[str, Any]: """ Loads environment variables from a .env file in the specified folder path. @@ -208,7 +210,9 @@ def load_env_vars(folder_path): return env_vars -def update_env_vars(env_vars, provider, model): +def update_env_vars( + env_vars: dict[str, Any], provider: str, model: str +) -> dict[str, Any] | None: """ Updates environment variables with the API key for the selected provider and model. @@ -220,15 +224,20 @@ def update_env_vars(env_vars, provider, model): Returns: - None """ - api_key_var = ENV_VARS.get( - provider, - [ - click.prompt( - f"Enter the environment variable name for your {provider.capitalize()} API key", - type=str, - ) - ], - )[0] + provider_config = cast( + list[str], + ENV_VARS.get( + provider, + [ + click.prompt( + f"Enter the environment variable name for your {provider.capitalize()} API key", + type=str, + ) + ], + ), + ) + + api_key_var = provider_config[0] if api_key_var not in env_vars: try: @@ -246,7 +255,7 @@ def update_env_vars(env_vars, provider, model): return env_vars -def write_env_file(folder_path, env_vars): +def write_env_file(folder_path: Path, env_vars: dict[str, Any]) -> None: """ Writes environment variables to a .env file in the specified folder. @@ -342,18 +351,18 @@ def get_crews(crew_path: str = "crew.py", require: bool = False) -> list[Crew]: return crew_instances -def get_crew_instance(module_attr) -> Crew | None: +def get_crew_instance(module_attr: Any) -> Crew | None: if ( callable(module_attr) and hasattr(module_attr, "is_crew_class") and module_attr.is_crew_class ): - return module_attr().crew() + return cast(Crew, module_attr().crew()) try: if (ismethod(module_attr) or isfunction(module_attr)) and get_type_hints( module_attr ).get("return") is Crew: - return module_attr() + return cast(Crew, module_attr()) except Exception: return None @@ -362,7 +371,7 @@ def get_crew_instance(module_attr) -> Crew | None: return None -def fetch_crews(module_attr) -> list[Crew]: +def fetch_crews(module_attr: Any) -> list[Crew]: crew_instances: list[Crew] = [] if crew_instance := get_crew_instance(module_attr): @@ -377,7 +386,7 @@ def fetch_crews(module_attr) -> list[Crew]: return crew_instances -def is_valid_tool(obj): +def is_valid_tool(obj: Any) -> bool: from crewai.tools.base_tool import Tool if isclass(obj): @@ -389,7 +398,7 @@ def is_valid_tool(obj): return isinstance(obj, Tool) -def extract_available_exports(dir_path: str = "src"): +def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]: """ Extract available tool classes from the project's __init__.py files. Only includes classes that inherit from BaseTool or functions decorated with @tool. @@ -419,7 +428,9 @@ def extract_available_exports(dir_path: str = "src"): raise SystemExit(1) from e -def build_env_with_tool_repository_credentials(repository_handle: str): +def build_env_with_tool_repository_credentials( + repository_handle: str, +) -> dict[str, Any]: repository_handle = repository_handle.upper().replace("-", "_") settings = Settings() @@ -472,7 +483,7 @@ def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]: sys.modules.pop("temp_module", None) -def _print_no_tools_warning(): +def _print_no_tools_warning() -> None: """ Display warning and usage instructions if no tools were found. """ diff --git a/lib/crewai/tests/cli/authentication/providers/test_okta.py b/lib/crewai/tests/cli/authentication/providers/test_okta.py index 5ceb441bf..5108b1bb6 100644 --- a/lib/crewai/tests/cli/authentication/providers/test_okta.py +++ b/lib/crewai/tests/cli/authentication/providers/test_okta.py @@ -37,6 +37,36 @@ class TestOktaProvider: provider = OktaProvider(settings) expected_url = "https://my-company.okta.com/oauth2/default/v1/device/authorize" assert provider.get_authorize_url() == expected_url + + def test_get_authorize_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777/v1/device/authorize" + assert provider.get_authorize_url() == expected_url + + def test_get_authorize_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/v1/device/authorize" + assert provider.get_authorize_url() == expected_url def test_get_token_url(self): expected_url = "https://test-domain.okta.com/oauth2/default/v1/token" @@ -53,6 +83,36 @@ class TestOktaProvider: expected_url = "https://another-domain.okta.com/oauth2/default/v1/token" assert provider.get_token_url() == expected_url + def test_get_token_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777/v1/token" + assert provider.get_token_url() == expected_url + + def test_get_token_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/v1/token" + assert provider.get_token_url() == expected_url + def test_get_jwks_url(self): expected_url = "https://test-domain.okta.com/oauth2/default/v1/keys" assert self.provider.get_jwks_url() == expected_url @@ -68,6 +128,36 @@ class TestOktaProvider: expected_url = "https://dev.okta.com/oauth2/default/v1/keys" assert provider.get_jwks_url() == expected_url + def test_get_jwks_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777/v1/keys" + assert provider.get_jwks_url() == expected_url + + def test_get_jwks_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_url = "https://test-domain.okta.com/oauth2/v1/keys" + assert provider.get_jwks_url() == expected_url + def test_get_issuer(self): expected_issuer = "https://test-domain.okta.com/oauth2/default" assert self.provider.get_issuer() == expected_issuer @@ -83,6 +173,36 @@ class TestOktaProvider: expected_issuer = "https://prod.okta.com/oauth2/default" assert provider.get_issuer() == expected_issuer + def test_get_issuer_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + provider = OktaProvider(settings) + expected_issuer = "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777" + assert provider.get_issuer() == expected_issuer + + def test_get_issuer_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + expected_issuer = "https://test-domain.okta.com" + assert provider.get_issuer() == expected_issuer + def test_get_audience(self): assert self.provider.get_audience() == "test-audience" @@ -100,3 +220,38 @@ class TestOktaProvider: def test_get_client_id(self): assert self.provider.get_client_id() == "test-client-id" + + def test_get_required_fields(self): + assert set(self.provider.get_required_fields()) == set(["authorization_server_name", "using_org_auth_server"]) + + def test_oauth2_base_url(self): + assert self.provider._oauth2_base_url() == "https://test-domain.okta.com/oauth2/default" + + def test_oauth2_base_url_with_custom_authorization_server_name(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": False, + "authorization_server_name": "my_auth_server_xxxAAA777" + } + ) + + provider = OktaProvider(settings) + assert provider._oauth2_base_url() == "https://test-domain.okta.com/oauth2/my_auth_server_xxxAAA777" + + def test_oauth2_base_url_when_using_org_auth_server(self): + settings = Oauth2Settings( + provider="okta", + domain="test-domain.okta.com", + client_id="test-client-id", + audience=None, + extra={ + "using_org_auth_server": True, + "authorization_server_name": None + } + ) + provider = OktaProvider(settings) + assert provider._oauth2_base_url() == "https://test-domain.okta.com/oauth2" \ No newline at end of file diff --git a/lib/crewai/tests/cli/enterprise/test_main.py b/lib/crewai/tests/cli/enterprise/test_main.py index 559aaaa14..e6be4e006 100644 --- a/lib/crewai/tests/cli/enterprise/test_main.py +++ b/lib/crewai/tests/cli/enterprise/test_main.py @@ -37,7 +37,8 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): 'audience': 'test_audience', 'domain': 'test.domain.com', 'device_authorization_client_id': 'test_client_id', - 'provider': 'workos' + 'provider': 'workos', + 'extra': {} } mock_requests_get.return_value = mock_response @@ -60,11 +61,12 @@ class TestEnterpriseConfigureCommand(unittest.TestCase): ('oauth2_provider', 'workos'), ('oauth2_audience', 'test_audience'), ('oauth2_client_id', 'test_client_id'), - ('oauth2_domain', 'test.domain.com') + ('oauth2_domain', 'test.domain.com'), + ('oauth2_extra', {}) ] actual_calls = self.mock_settings_command.set.call_args_list - self.assertEqual(len(actual_calls), 5) + self.assertEqual(len(actual_calls), 6) for i, (key, value) in enumerate(expected_calls): call_args = actual_calls[i][0]