From bffe8de59714e01b0c1be0de14e240a6df81f57e Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Sat, 10 May 2025 09:34:34 -0400 Subject: [PATCH] Add support for user/org level microagents (#8402) Co-authored-by: openhands --- .../__tests__/services/observations.test.tsx | 6 +- openhands/runtime/base.py | 199 +++++++++++++++--- openhands/server/routes/secrets.py | 49 +++-- openhands/server/routes/settings.py | 5 - openhands/storage/data_models/user_secrets.py | 7 +- 5 files changed, 211 insertions(+), 55 deletions(-) diff --git a/frontend/__tests__/services/observations.test.tsx b/frontend/__tests__/services/observations.test.tsx index 1a6d630dca..9c2d55656f 100644 --- a/frontend/__tests__/services/observations.test.tsx +++ b/frontend/__tests__/services/observations.test.tsx @@ -32,7 +32,7 @@ describe("handleObservationMessage", () => { screenshot: "base64-screenshot-data", }, }; - + handleObservationMessage(message); // Check that setScreenshotSrc and setUrl were called with the correct values @@ -52,11 +52,11 @@ describe("handleObservationMessage", () => { screenshot: "base64-screenshot-data", }, }; - + handleObservationMessage(message); // Check that setScreenshotSrc and setUrl were called with the correct values expect(store.dispatch).toHaveBeenCalledWith(setScreenshotSrc("base64-screenshot-data")); expect(store.dispatch).toHaveBeenCalledWith(setUrl("https://example.com")); }); -}); \ No newline at end of file +}); diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 2d49c419eb..b494e380e3 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -516,21 +516,187 @@ fi self.log('info', 'Git pre-commit hook installed successfully') + def _load_microagents_from_directory( + self, microagents_dir: Path, source_description: str + ) -> list[BaseMicroagent]: + """Load microagents from a directory. + + Args: + microagents_dir: Path to the directory containing microagents + source_description: Description of the source for logging purposes + + Returns: + A list of loaded microagents + """ + loaded_microagents: list[BaseMicroagent] = [] + files = self.list_files(str(microagents_dir)) + + if not files: + return loaded_microagents + + self.log( + 'info', + f'Found {len(files)} files in {source_description} microagents directory', + ) + zip_path = self.copy_from(str(microagents_dir)) + microagent_folder = tempfile.mkdtemp() + + try: + with ZipFile(zip_path, 'r') as zip_file: + zip_file.extractall(microagent_folder) + + zip_path.unlink() + repo_agents, knowledge_agents = load_microagents_from_dir(microagent_folder) + + self.log( + 'info', + f'Loaded {len(repo_agents)} repo agents and {len(knowledge_agents)} knowledge agents from {source_description}', + ) + + loaded_microagents.extend(repo_agents.values()) + loaded_microagents.extend(knowledge_agents.values()) + finally: + shutil.rmtree(microagent_folder) + + return loaded_microagents + + def _get_authenticated_git_url(self, repo_path: str) -> str: + """Get an authenticated git URL for a repository. + + Args: + repo_path: Repository path (e.g., "github.com/acme-co/api") + + Returns: + Authenticated git URL if credentials are available, otherwise regular HTTPS URL + """ + remote_url = f'https://{repo_path}.git' + + # Determine provider from repo path + provider = None + if 'github.com' in repo_path: + provider = ProviderType.GITHUB + elif 'gitlab.com' in repo_path: + provider = ProviderType.GITLAB + + # Add authentication if available + if ( + provider + and self.git_provider_tokens + and provider in self.git_provider_tokens + ): + git_token = self.git_provider_tokens[provider].token + if git_token: + if provider == ProviderType.GITLAB: + remote_url = f'https://oauth2:{git_token.get_secret_value()}@{repo_path.replace("gitlab.com/", "")}.git' + else: + remote_url = f'https://{git_token.get_secret_value()}@{repo_path.replace("github.com/", "")}.git' + + return remote_url + + def get_microagents_from_org_or_user( + self, selected_repository: str + ) -> list[BaseMicroagent]: + """Load microagents from the organization or user level .openhands repository. + + For example, if the repository is github.com/acme-co/api, this will check if + github.com/acme-co/.openhands exists. If it does, it will clone it and load + the microagents from the ./microagents/ folder. + + Args: + selected_repository: The repository path (e.g., "github.com/acme-co/api") + + Returns: + A list of loaded microagents from the org/user level repository + """ + loaded_microagents: list[BaseMicroagent] = [] + workspace_root = Path(self.config.workspace_mount_path_in_sandbox) + + repo_parts = selected_repository.split('/') + if len(repo_parts) < 2: + return loaded_microagents + + # Extract the domain and org/user name + domain = repo_parts[0] if len(repo_parts) > 2 else 'github.com' + org_name = repo_parts[-2] + + # Construct the org-level .openhands repo path + org_openhands_repo = f'{domain}/{org_name}/.openhands' + if domain not in org_openhands_repo: + org_openhands_repo = f'github.com/{org_openhands_repo}' + + self.log( + 'info', + f'Checking for org-level microagents at {org_openhands_repo}', + ) + + # Try to clone the org-level .openhands repo + try: + # Create a temporary directory for the org-level repo + org_repo_dir = workspace_root / f'org_openhands_{org_name}' + + # Get authenticated URL and do a shallow clone (--depth 1) for efficiency + remote_url = self._get_authenticated_git_url(org_openhands_repo) + clone_cmd = f"git clone --depth 1 {remote_url} {org_repo_dir} 2>/dev/null || echo 'Org repo not found'" + + action = CmdRunAction(command=clone_cmd) + obs = self.run_action(action) + + if ( + isinstance(obs, CmdOutputObservation) + and obs.exit_code == 0 + and 'Org repo not found' not in obs.content + ): + self.log( + 'info', + f'Successfully cloned org-level microagents from {org_openhands_repo}', + ) + + # Load microagents from the org-level repo + org_microagents_dir = org_repo_dir / 'microagents' + loaded_microagents = self._load_microagents_from_directory( + org_microagents_dir, 'org-level' + ) + + # Clean up the org repo directory + shutil.rmtree(org_repo_dir) + else: + self.log( + 'info', + f'No org-level microagents found at {org_openhands_repo}', + ) + + except Exception as e: + self.log('error', f'Error loading org-level microagents: {str(e)}') + + return loaded_microagents + def get_microagents_from_selected_repo( self, selected_repository: str | None ) -> list[BaseMicroagent]: """Load microagents from the selected repository. If selected_repository is None, load microagents from the current workspace. This is the main entry point for loading microagents. + + This method also checks for user/org level microagents stored in a .openhands repository. + For example, if the repository is github.com/acme-co/api, it will also check for + github.com/acme-co/.openhands and load microagents from there if it exists. """ loaded_microagents: list[BaseMicroagent] = [] workspace_root = Path(self.config.workspace_mount_path_in_sandbox) microagents_dir = workspace_root / '.openhands' / 'microagents' repo_root = None + + # Check for user/org level microagents if a repository is selected if selected_repository: + # Load microagents from the org/user level repository + org_microagents = self.get_microagents_from_org_or_user(selected_repository) + loaded_microagents.extend(org_microagents) + + # Continue with repository-specific microagents repo_root = workspace_root / selected_repository.split('/')[-1] microagents_dir = repo_root / '.openhands' / 'microagents' + self.log( 'info', f'Selected repo: {selected_repository}, loading microagents from {microagents_dir} (inside runtime)', @@ -562,35 +728,10 @@ fi ) # Load microagents from directory - files = self.list_files(str(microagents_dir)) - if files: - self.log('info', f'Found {len(files)} files in microagents directory.') - zip_path = self.copy_from(str(microagents_dir)) - microagent_folder = tempfile.mkdtemp() - - # Properly handle the zip file - with ZipFile(zip_path, 'r') as zip_file: - zip_file.extractall(microagent_folder) - - # Add debug print of directory structure - self.log('debug', 'Microagent folder structure:') - for root, _, files in os.walk(microagent_folder): - relative_path = os.path.relpath(root, microagent_folder) - self.log('debug', f'Directory: {relative_path}/') - for file in files: - self.log('debug', f' File: {os.path.join(relative_path, file)}') - - # Clean up the temporary zip file - zip_path.unlink() - # Load all microagents using the existing function - repo_agents, knowledge_agents = load_microagents_from_dir(microagent_folder) - self.log( - 'info', - f'Loaded {len(repo_agents)} repo agents and {len(knowledge_agents)} knowledge agents', - ) - loaded_microagents.extend(repo_agents.values()) - loaded_microagents.extend(knowledge_agents.values()) - shutil.rmtree(microagent_folder) + repo_microagents = self._load_microagents_from_directory( + microagents_dir, 'repository' + ) + loaded_microagents.extend(repo_microagents) return loaded_microagents diff --git a/openhands/server/routes/secrets.py b/openhands/server/routes/secrets.py index 3db41ef0a6..7896b8aba6 100644 --- a/openhands/server/routes/secrets.py +++ b/openhands/server/routes/secrets.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, status from fastapi.responses import JSONResponse from openhands.core.logger import openhands_logger as logger -from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken +from openhands.integrations.provider import PROVIDER_TOKEN_TYPE from openhands.integrations.service_types import ProviderType from openhands.integrations.utils import validate_provider_token from openhands.server.settings import ( @@ -55,31 +55,47 @@ async def invalidate_legacy_secrets_store( def process_token_validation_result( - confirmed_token_type: ProviderType | None, - token_type: ProviderType): - + confirmed_token_type: ProviderType | None, token_type: ProviderType +): if not confirmed_token_type or confirmed_token_type != token_type: - return f'Invalid token. Please make sure it is a valid {token_type.value} token.' - + return ( + f'Invalid token. Please make sure it is a valid {token_type.value} token.' + ) + return '' + async def check_provider_tokens( incoming_provider_tokens: POSTProviderModel, - existing_provider_tokens: PROVIDER_TOKEN_TYPE | None) -> str: - + existing_provider_tokens: PROVIDER_TOKEN_TYPE | None, +) -> str: msg = '' if incoming_provider_tokens.provider_tokens: # Determine whether tokens are valid for token_type, token_value in incoming_provider_tokens.provider_tokens.items(): if token_value.token: - confirmed_token_type = await validate_provider_token(token_value.token, token_value.host) # FE always sends latest host + confirmed_token_type = await validate_provider_token( + token_value.token, token_value.host + ) # FE always sends latest host msg = process_token_validation_result(confirmed_token_type, token_type) - existing_token = existing_provider_tokens.get(token_type, None) if existing_provider_tokens else None - if existing_token and (existing_token.host != token_value.host) and existing_token.token: - confirmed_token_type = await validate_provider_token(existing_token.token, token_value.host) # Host has changed, check it against existing token + existing_token = ( + existing_provider_tokens.get(token_type, None) + if existing_provider_tokens + else None + ) + if ( + existing_token + and (existing_token.host != token_value.host) + and existing_token.token + ): + confirmed_token_type = await validate_provider_token( + existing_token.token, token_value.host + ) # Host has changed, check it against existing token if not confirmed_token_type or confirmed_token_type != token_type: - msg = process_token_validation_result(confirmed_token_type, token_type) + msg = process_token_validation_result( + confirmed_token_type, token_type + ) return msg @@ -88,9 +104,8 @@ async def check_provider_tokens( async def store_provider_tokens( provider_info: POSTProviderModel, secrets_store: SecretsStore = Depends(get_secrets_store), - provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens) + provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), ) -> JSONResponse: - provider_err_msg = await check_provider_tokens(provider_info, provider_tokens) if provider_err_msg: return JSONResponse( @@ -113,7 +128,9 @@ async def store_provider_tokens( if existing_token and existing_token.token: provider_info.provider_tokens[provider] = existing_token - provider_info.provider_tokens[provider] = provider_info.provider_tokens[provider].model_copy(update={'host': token_value.host}) + provider_info.provider_tokens[provider] = provider_info.provider_tokens[ + provider + ].model_copy(update={'host': token_value.host}) updated_secrets = user_secrets.model_copy( update={'provider_tokens': provider_info.provider_tokens} diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 27020c5cb5..1d9ac040b2 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -11,7 +11,6 @@ from openhands.server.settings import ( GETSettingsModel, ) from openhands.server.shared import config -from openhands.server.types import AppMode from openhands.server.user_auth import ( get_provider_tokens, get_secrets_store, @@ -20,7 +19,6 @@ from openhands.server.user_auth import ( from openhands.storage.data_models.settings import Settings from openhands.storage.secrets.secrets_store import SecretsStore from openhands.storage.settings.settings_store import SettingsStore -from openhands.server.shared import server_config app = APIRouter(prefix='/api') @@ -47,13 +45,11 @@ async def load_settings( content={'error': 'Settings not found'}, ) - # On initial load, user secrets may not be populated with values migrated from settings store user_secrets = await invalidate_legacy_secrets_store( settings, settings_store, secrets_store ) - # If invalidation is successful, then the returned user secrets holds the most recent values git_providers = ( user_secrets.provider_tokens if user_secrets else provider_tokens @@ -65,7 +61,6 @@ async def load_settings( if provider_token.token or provider_token.user_id: provider_tokens_set[provider_type] = provider_token.host - settings_with_token_data = GETSettingsModel( **settings.model_dump(exclude='secrets_store'), llm_api_key_set=settings.llm_api_key is not None diff --git a/openhands/storage/data_models/user_secrets.py b/openhands/storage/data_models/user_secrets.py index 557b72497b..1b4ce2e097 100644 --- a/openhands/storage/data_models/user_secrets.py +++ b/openhands/storage/data_models/user_secrets.py @@ -56,13 +56,16 @@ class UserSecrets(BaseModel): token = None if provider_token.token: - token = provider_token.token.get_secret_value() if expose_secrets else pydantic_encoder(provider_token.token) + token = ( + provider_token.token.get_secret_value() + if expose_secrets + else pydantic_encoder(provider_token.token) + ) tokens[token_type_str] = { 'token': token, 'host': provider_token.host, 'user_id': provider_token.user_id, - } return tokens