mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add support for user/org level microagents (#8402)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
f0bb7de1c6
commit
bffe8de597
@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user