Add support for user/org level microagents (#8402)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan 2025-05-10 09:34:34 -04:00 committed by GitHub
parent f0bb7de1c6
commit bffe8de597
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 211 additions and 55 deletions

View File

@ -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"));
});
});
});

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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