fix(backend): organizational skills do not trigger (v1 conversations) (#12037)

This commit is contained in:
Hiep Le
2025-12-14 23:31:07 +07:00
committed by GitHub
parent eb9a22ef7e
commit f2def8fd7f
4 changed files with 1474 additions and 1 deletions

View File

@@ -14,6 +14,9 @@ from pathlib import Path
import openhands
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.integrations.service_types import AuthenticationError
from openhands.sdk.context.skills import Skill
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
@@ -119,6 +122,96 @@ def _determine_repo_root(working_dir: str, selected_repository: str | None) -> s
return working_dir
async def _is_gitlab_repository(repo_name: str, user_context: UserContext) -> bool:
"""Check if a repository is hosted on GitLab.
Args:
repo_name: Repository name (e.g., "gitlab.com/org/repo" or "org/repo")
user_context: UserContext to access provider handler
Returns:
True if the repository is hosted on GitLab, False otherwise
"""
try:
provider_handler = await user_context.get_provider_handler() # type: ignore[attr-defined]
repository = await provider_handler.verify_repo_provider(repo_name)
return repository.git_provider == ProviderType.GITLAB
except Exception:
# If we can't determine the provider, assume it's not GitLab
# This is a safe fallback since we'll just use the default .openhands
return False
async def _is_azure_devops_repository(
repo_name: str, user_context: UserContext
) -> bool:
"""Check if a repository is hosted on Azure DevOps.
Args:
repo_name: Repository name (e.g., "org/project/repo")
user_context: UserContext to access provider handler
Returns:
True if the repository is hosted on Azure DevOps, False otherwise
"""
try:
provider_handler = await user_context.get_provider_handler() # type: ignore[attr-defined]
repository = await provider_handler.verify_repo_provider(repo_name)
return repository.git_provider == ProviderType.AZURE_DEVOPS
except Exception:
# If we can't determine the provider, assume it's not Azure DevOps
return False
async def _determine_org_repo_path(
selected_repository: str, user_context: UserContext
) -> tuple[str, str]:
"""Determine the organization repository path and organization name.
Args:
selected_repository: Repository name (e.g., 'owner/repo' or 'org/project/repo')
user_context: UserContext to access provider handler
Returns:
Tuple of (org_repo_path, org_name) where:
- org_repo_path: Full path to org-level config repo
- org_name: Organization name extracted from repository
Examples:
- GitHub/Bitbucket: ('owner/.openhands', 'owner')
- GitLab: ('owner/openhands-config', 'owner')
- Azure DevOps: ('org/openhands-config/openhands-config', 'org')
"""
repo_parts = selected_repository.split('/')
# Determine repository type
is_azure_devops = await _is_azure_devops_repository(
selected_repository, user_context
)
is_gitlab = await _is_gitlab_repository(selected_repository, user_context)
# Extract the org/user name
# Azure DevOps format: org/project/repo (3 parts) - extract org (first part)
# GitHub/GitLab/Bitbucket format: owner/repo (2 parts) - extract owner (first part)
if is_azure_devops and len(repo_parts) >= 3:
org_name = repo_parts[0] # Get org from org/project/repo
else:
org_name = repo_parts[-2] # Get owner from owner/repo
# For GitLab and Azure DevOps, use openhands-config (since .openhands is not a valid repo name)
# For other providers, use .openhands
if is_gitlab:
org_openhands_repo = f'{org_name}/openhands-config'
elif is_azure_devops:
# Azure DevOps format: org/project/repo
# For org-level config, use: org/openhands-config/openhands-config
org_openhands_repo = f'{org_name}/openhands-config/openhands-config'
else:
org_openhands_repo = f'{org_name}/.openhands'
return org_openhands_repo, org_name
async def _read_file_from_workspace(
workspace: AsyncRemoteWorkspace, file_path: str, working_dir: str
) -> str | None:
@@ -322,6 +415,248 @@ async def load_repo_skills(
return []
def _validate_repository_for_org_skills(selected_repository: str) -> bool:
"""Validate that the repository path has sufficient parts for org skills.
Args:
selected_repository: Repository name (e.g., 'owner/repo')
Returns:
True if repository is valid for org skills loading, False otherwise
"""
repo_parts = selected_repository.split('/')
if len(repo_parts) < 2:
_logger.warning(
f'Repository path has insufficient parts ({len(repo_parts)} < 2), skipping org-level skills'
)
return False
return True
async def _get_org_repository_url(
org_openhands_repo: str, user_context: UserContext
) -> str | None:
"""Get authenticated Git URL for organization repository.
Args:
org_openhands_repo: Organization repository path
user_context: UserContext to access authentication
Returns:
Authenticated Git URL if successful, None otherwise
"""
try:
remote_url = await user_context.get_authenticated_git_url(org_openhands_repo)
return remote_url
except AuthenticationError as e:
_logger.debug(
f'org-level skill directory {org_openhands_repo} not found: {str(e)}'
)
return None
except Exception as e:
_logger.debug(
f'Failed to get authenticated URL for {org_openhands_repo}: {str(e)}'
)
return None
async def _clone_org_repository(
workspace: AsyncRemoteWorkspace,
remote_url: str,
org_repo_dir: str,
working_dir: str,
org_openhands_repo: str,
) -> bool:
"""Clone organization repository to temporary directory.
Args:
workspace: AsyncRemoteWorkspace to execute commands
remote_url: Authenticated Git URL
org_repo_dir: Temporary directory path for cloning
working_dir: Working directory for command execution
org_openhands_repo: Organization repository path (for logging)
Returns:
True if clone successful, False otherwise
"""
_logger.debug(f'Creating temporary directory for org repo: {org_repo_dir}')
# Clone the repo (shallow clone for efficiency)
clone_cmd = f'GIT_TERMINAL_PROMPT=0 git clone --depth 1 {remote_url} {org_repo_dir}'
_logger.info('Executing clone command for org-level repo')
result = await workspace.execute_command(clone_cmd, working_dir, timeout=120.0)
if result.exit_code != 0:
_logger.info(
f'No org-level skills found at {org_openhands_repo} (exit_code: {result.exit_code})'
)
_logger.debug(f'Clone command output: {result.stderr}')
return False
_logger.info(f'Successfully cloned org-level skills from {org_openhands_repo}')
return True
async def _load_skills_from_org_directories(
workspace: AsyncRemoteWorkspace, org_repo_dir: str, working_dir: str
) -> tuple[list[Skill], list[Skill]]:
"""Load skills from both skills/ and microagents/ directories in org repo.
Args:
workspace: AsyncRemoteWorkspace to execute commands
org_repo_dir: Path to cloned organization repository
working_dir: Working directory for command execution
Returns:
Tuple of (skills_dir_skills, microagents_dir_skills)
"""
skills_dir = f'{org_repo_dir}/skills'
skills_dir_skills = await _find_and_load_skill_md_files(
workspace, skills_dir, working_dir
)
microagents_dir = f'{org_repo_dir}/microagents'
microagents_dir_skills = await _find_and_load_skill_md_files(
workspace, microagents_dir, working_dir
)
return skills_dir_skills, microagents_dir_skills
def _merge_org_skills_with_precedence(
skills_dir_skills: list[Skill], microagents_dir_skills: list[Skill]
) -> list[Skill]:
"""Merge skills from skills/ and microagents/ with proper precedence.
Precedence: skills/ > microagents/ (skills/ overrides microagents/ for same name)
Args:
skills_dir_skills: Skills loaded from skills/ directory
microagents_dir_skills: Skills loaded from microagents/ directory
Returns:
Merged list of skills with proper precedence applied
"""
skills_by_name = {}
for skill in microagents_dir_skills + skills_dir_skills:
# Later sources (skills/) override earlier ones (microagents/)
if skill.name not in skills_by_name:
skills_by_name[skill.name] = skill
else:
_logger.debug(
f'Overriding org skill "{skill.name}" from microagents/ with skills/'
)
skills_by_name[skill.name] = skill
return list(skills_by_name.values())
async def _cleanup_org_repository(
workspace: AsyncRemoteWorkspace, org_repo_dir: str, working_dir: str
) -> None:
"""Clean up cloned organization repository directory.
Args:
workspace: AsyncRemoteWorkspace to execute commands
org_repo_dir: Path to cloned organization repository
working_dir: Working directory for command execution
"""
cleanup_cmd = f'rm -rf {org_repo_dir}'
await workspace.execute_command(cleanup_cmd, working_dir, timeout=10.0)
async def load_org_skills(
workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
working_dir: str,
user_context: UserContext,
) -> list[Skill]:
"""Load organization-level skills from the organization 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 skills from both the ./skills/ and ./microagents/ folders.
For GitLab repositories, it will use openhands-config instead of .openhands
since GitLab doesn't support repository names starting with non-alphanumeric
characters.
For Azure DevOps repositories, it will use org/openhands-config/openhands-config
format to match Azure DevOps's three-part repository structure (org/project/repo).
Args:
workspace: AsyncRemoteWorkspace to execute commands in the sandbox
selected_repository: Repository name (e.g., 'owner/repo') or None
working_dir: Working directory path
user_context: UserContext to access provider handler and authentication
Returns:
List of Skill objects loaded from organization repository.
Returns empty list if no repository selected or on errors.
"""
if not selected_repository:
return []
try:
_logger.debug(
f'Starting org-level skill loading for repository: {selected_repository}'
)
# Validate repository path
if not _validate_repository_for_org_skills(selected_repository):
return []
# Determine organization repository path
org_openhands_repo, org_name = await _determine_org_repo_path(
selected_repository, user_context
)
_logger.info(f'Checking for org-level skills at {org_openhands_repo}')
# Get authenticated URL for org repository
remote_url = await _get_org_repository_url(org_openhands_repo, user_context)
if not remote_url:
return []
# Clone the organization repository
org_repo_dir = f'{working_dir}/_org_openhands_{org_name}'
clone_success = await _clone_org_repository(
workspace, remote_url, org_repo_dir, working_dir, org_openhands_repo
)
if not clone_success:
return []
# Load skills from both skills/ and microagents/ directories
(
skills_dir_skills,
microagents_dir_skills,
) = await _load_skills_from_org_directories(
workspace, org_repo_dir, working_dir
)
# Merge skills with proper precedence
loaded_skills = _merge_org_skills_with_precedence(
skills_dir_skills, microagents_dir_skills
)
_logger.info(
f'Loaded {len(loaded_skills)} skills from org-level repository {org_openhands_repo}: {[s.name for s in loaded_skills]}'
)
# Clean up the org repo directory
await _cleanup_org_repository(workspace, org_repo_dir, working_dir)
return loaded_skills
except AuthenticationError as e:
_logger.debug(f'org-level skill directory not found: {str(e)}')
return []
except Exception as e:
_logger.warning(f'Failed to load org-level skills: {str(e)}')
return []
def merge_skills(skill_lists: list[list[Skill]]) -> list[Skill]:
"""Merge multiple skill lists, avoiding duplicates by name.