diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index ac2a2e29f..0f3d9d46d 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -200,6 +200,7 @@ class Agent(BaseAgent): _times_executed: int = PrivateAttr(default=0) _mcp_resolver: MCPToolResolver | None = PrivateAttr(default=None) _last_messages: list[LLMMessage] = PrivateAttr(default_factory=list) + _from_repository_resolved: bool = PrivateAttr(default=False) max_execution_time: int | None = Field( default=None, description="Maximum execution time for an agent to execute a task", @@ -346,11 +347,42 @@ class Agent(BaseAgent): @model_validator(mode="before") @classmethod def validate_from_repository(cls, v: Any) -> dict[str, Any] | None | Any: - """Merge repository agent config with provided values before validation.""" - if v is not None and (from_repository := v.get("from_repository")): - return load_agent_from_repository(from_repository) | v + """Defer repository resolution until the agent first runs. + + Loading an agent from the repository requires an authenticated network + call. Performing it here — during construction — forces that call while a + crew is being loaded, before the runtime has wired deployment auth, which + breaks ``from_repository`` agents in deployed environments. Instead we keep + ``from_repository`` and seed the required identity fields with placeholders + so the model validates now; the real definition is fetched on first use + (see ``_resolve_from_repository``). + """ + if isinstance(v, dict) and v.get("from_repository"): + for field in ("role", "goal", "backstory"): + v.setdefault(field, "") return v + def _resolve_from_repository(self) -> None: + """Fetch and apply the repository agent definition on first use. + + Values supplied explicitly at construction take precedence; the + repository fills in everything else (including the placeholder identity + fields). Runs at most once and only when ``from_repository`` is set. + """ + if not self.from_repository or self._from_repository_resolved: + return + + attributes = load_agent_from_repository(self.from_repository) + identity_fields = ("role", "goal", "backstory") + for key, value in attributes.items(): + if key in identity_fields: + if not getattr(self, key, None): + setattr(self, key, value) + elif key not in self.model_fields_set and hasattr(self, key): + setattr(self, key, value) + + self._from_repository_resolved = True + @model_validator(mode="after") def post_init_setup(self) -> Self: """Initialize LLM, executor, code tools, and skills after model creation.""" @@ -804,6 +836,7 @@ class Agent(BaseAgent): ValueError: If the max execution time is not a positive integer. RuntimeError: If the agent execution fails for other reasons. """ + self._resolve_from_repository() task_prompt = self._prepare_task_execution(task, context) knowledge_config = get_knowledge_config(self) @@ -940,6 +973,7 @@ class Agent(BaseAgent): ValueError: If the max execution time is not a positive integer. RuntimeError: If the agent execution fails for other reasons. """ + self._resolve_from_repository() task_prompt = self._prepare_task_execution(task, context) knowledge_config = get_knowledge_config(self) @@ -1418,6 +1452,7 @@ class Agent(BaseAgent): Returns: Tuple of (executor, inputs, agent_info, parsed_tools) ready for execution. """ + self._resolve_from_repository() if self.apps: platform_tools = self.get_platform_tools(self.apps) if platform_tools: diff --git a/lib/crewai/tests/agents/test_agent.py b/lib/crewai/tests/agents/test_agent.py index 0435bed94..b2c75fd92 100644 --- a/lib/crewai/tests/agents/test_agent.py +++ b/lib/crewai/tests/agents/test_agent.py @@ -2207,6 +2207,11 @@ def test_agent_from_repository(mock_get_agent, mock_get_auth_token): mock_get_agent.return_value = mock_get_response agent = Agent(from_repository="test_agent") + # Resolution is deferred: nothing is fetched at construction. + assert mock_get_agent.called is False + + # Resolution happens on first execution (triggered here directly). + agent._resolve_from_repository() assert agent.role == "test role" assert agent.goal == "test goal" @@ -2219,6 +2224,32 @@ def test_agent_from_repository(mock_get_agent, mock_get_auth_token): assert agent.tools[1].file_path == "test.txt" +@patch("crewai.plus_api.PlusAPI.get_agent") +def test_agent_from_repository_is_deferred_until_execution( + mock_get_agent, mock_get_auth_token +): + mock_get_response = MagicMock() + mock_get_response.status_code = 200 + mock_get_response.json.return_value = { + "role": "test role", + "goal": "test goal", + "backstory": "test backstory", + } + mock_get_agent.return_value = mock_get_response + + # Construction must not touch the network — this is what lets from_repository + # agents be built while a crew loads, before deployment auth is wired. + agent = Agent(from_repository="test_agent") + assert mock_get_agent.called is False + assert agent.role == "" + + # Executing the agent resolves the definition; a second run does not refetch. + agent._resolve_from_repository() + agent._resolve_from_repository() + assert agent.role == "test role" + assert mock_get_agent.call_count == 1 + + @patch("crewai.plus_api.PlusAPI.get_agent") def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth_token): from crewai_tools import SerperDevTool @@ -2235,6 +2266,7 @@ def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth } mock_get_agent.return_value = mock_get_response agent = Agent(from_repository="test_agent", role="Custom Role") + agent._resolve_from_repository() assert agent.role == "Custom Role" assert agent.goal == "test goal" @@ -2259,11 +2291,12 @@ def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_ ], } mock_get_agent.return_value = mock_get_response + agent = Agent(from_repository="test_agent") with pytest.raises( AgentRepositoryError, match="Tool DoesNotExist could not be loaded: module 'crewai_tools' has no attribute 'DoesNotExist'", ): - Agent(from_repository="test_agent") + agent._resolve_from_repository() @patch("crewai.plus_api.PlusAPI.get_agent") @@ -2272,11 +2305,12 @@ def test_agent_from_repository_internal_error(mock_get_agent, mock_get_auth_toke mock_get_response.status_code = 500 mock_get_response.text = "Internal server error" mock_get_agent.return_value = mock_get_response + agent = Agent(from_repository="test_agent") with pytest.raises( AgentRepositoryError, match="Agent test_agent could not be loaded: Internal server error", ): - Agent(from_repository="test_agent") + agent._resolve_from_repository() @patch("crewai.plus_api.PlusAPI.get_agent") @@ -2285,11 +2319,12 @@ def test_agent_from_repository_agent_not_found(mock_get_agent, mock_get_auth_tok mock_get_response.status_code = 404 mock_get_response.text = "Agent not found" mock_get_agent.return_value = mock_get_response + agent = Agent(from_repository="test_agent") with pytest.raises( AgentRepositoryError, match="Agent test_agent does not exist, make sure the name is correct or the agent is available on your organization", ): - Agent(from_repository="test_agent") + agent._resolve_from_repository() @patch("crewai.plus_api.PlusAPI.get_agent") @@ -2314,6 +2349,7 @@ def test_agent_from_repository_displays_org_info( mock_get_agent.return_value = mock_get_response agent = Agent(from_repository="test_agent") + agent._resolve_from_repository() mock_console.print.assert_any_call( "Fetching agent from organization: Test Organization (test-org-uuid)", @@ -2341,11 +2377,12 @@ def test_agent_from_repository_without_org_set( mock_get_response.text = "Unauthorized access" mock_get_agent.return_value = mock_get_response + agent = Agent(from_repository="test_agent") with pytest.raises( AgentRepositoryError, match="Agent test_agent could not be loaded: Unauthorized access", ): - Agent(from_repository="test_agent") + agent._resolve_from_repository() mock_console.print.assert_any_call( "No organization currently set. We recommend setting one before using: `crewai org switch ` command.",