mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor: improve the get microagents API (#9958)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
parent
c2e860fe92
commit
10ae481b91
@ -5,6 +5,7 @@ from typing import Any
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
@ -13,9 +14,11 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
@ -64,6 +67,50 @@ class BitBucketService(BaseGitService, GitService):
|
||||
"""Get latest working token of the user."""
|
||||
return self.token
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{microagents_path}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'commit_file'
|
||||
and item['path'].endswith('.md')
|
||||
and not item['path'].endswith('README.md')
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['path'].split('/')[-1]
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
return status_code == 401
|
||||
|
||||
@ -286,6 +333,8 @@ class BitBucketService(BaseGitService, GitService):
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
uuid = data.get('uuid', '')
|
||||
main_branch = data.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=uuid,
|
||||
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
|
||||
@ -298,6 +347,7 @@ class BitBucketService(BaseGitService, GitService):
|
||||
if data.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
main_branch=main_branch,
|
||||
)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
@ -385,6 +435,41 @@ class BitBucketService(BaseGitService, GitService):
|
||||
# Return the URL to the pull request
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from Bitbucket repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Step 1: Get repository details using existing method
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
|
||||
if not repo_details.main_branch:
|
||||
logger.warning(
|
||||
f'No main branch found in repository info for {repository}. '
|
||||
f'Repository response: mainbranch field missing'
|
||||
)
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
# Step 2: Get file content using the main branch
|
||||
file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}'
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
|
||||
bitbucket_service_cls = os.environ.get(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS',
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
@ -24,6 +25,7 @@ from openhands.integrations.service_types import (
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
@ -88,6 +90,36 @@ class GitHubService(BaseGitService, GitService):
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
return self.token
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/{microagents_path}'
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'file'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return f'{microagents_path}/{item["name"]}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
@ -537,6 +569,29 @@ class GitHubService(BaseGitService, GitService):
|
||||
# Return the HTML URL of the created PR
|
||||
return response['html_url']
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitHub repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
file_url = f'{self.BASE_URL}/repos/{repository}/contents/{file_path}'
|
||||
|
||||
file_data, _ = await self._make_request(file_url)
|
||||
file_content = base64.b64decode(file_data['content']).decode('utf-8')
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(file_content, file_path)
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS',
|
||||
|
||||
@ -17,6 +17,7 @@ from openhands.integrations.service_types import (
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
@ -81,6 +82,40 @@ class GitLabService(BaseGitService, GitService):
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
return self.token
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return (
|
||||
f'{self.BASE_URL}/projects/{project_id}/repository/files/.cursorrules/raw'
|
||||
)
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return f'{self.BASE_URL}/projects/{project_id}/repository/tree'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict:
|
||||
"""Get parameters for the microagents directory request."""
|
||||
return {'path': microagents_path, 'recursive': 'true'}
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'blob'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
@ -117,7 +152,11 @@ class GitLabService(BaseGitService, GitService):
|
||||
if 'Link' in response.headers:
|
||||
headers['Link'] = response.headers['Link']
|
||||
|
||||
return response.json(), headers
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if 'application/json' in content_type:
|
||||
return response.json(), headers
|
||||
else:
|
||||
return response.text, headers
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
@ -523,6 +562,55 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return response['web_url']
|
||||
|
||||
def _extract_project_id(self, repository: str) -> str:
|
||||
"""Extract project_id from repository name for GitLab API calls.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
|
||||
Returns:
|
||||
URL-encoded project ID for GitLab API
|
||||
"""
|
||||
if '/' in repository:
|
||||
parts = repository.split('/')
|
||||
if len(parts) >= 3 and '.' in parts[0]:
|
||||
# Self-hosted GitLab: 'domain/owner/repo' -> 'owner/repo'
|
||||
project_id = '/'.join(parts[1:]).replace('/', '%2F')
|
||||
else:
|
||||
# Regular GitLab: 'owner/repo' -> 'owner/repo'
|
||||
project_id = repository.replace('/', '%2F')
|
||||
else:
|
||||
project_id = repository
|
||||
|
||||
return project_id
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitLab repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Extract project_id from repository name
|
||||
project_id = self._extract_project_id(repository)
|
||||
|
||||
encoded_file_path = file_path.replace('/', '%2F')
|
||||
base_url = f'{self.BASE_URL}/projects/{project_id}'
|
||||
file_url = f'{base_url}/repository/files/{encoded_file_path}/raw'
|
||||
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@ -22,11 +22,14 @@ from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
GitService,
|
||||
MicroagentParseError,
|
||||
ProviderType,
|
||||
Repository,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
@ -407,6 +410,104 @@ class ProviderHandler:
|
||||
|
||||
return main_branches + other_branches
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Get microagents from a repository using the appropriate service.
|
||||
|
||||
Args:
|
||||
repository: Repository name in the format 'owner/repo'
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
# Try all available providers in order
|
||||
errors = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
result = await service.get_microagents(repository)
|
||||
# Only return early if we got a non-empty result
|
||||
if result:
|
||||
return result
|
||||
# If we got an empty array, continue checking other providers
|
||||
logger.debug(
|
||||
f'No microagents found on {provider} for {repository}, trying other providers'
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
logger.warning(
|
||||
f'Error fetching microagents from {provider} for {repository}: {e}'
|
||||
)
|
||||
|
||||
# If all providers failed or returned empty results, return empty array
|
||||
if errors:
|
||||
logger.error(
|
||||
f'Failed to fetch microagents for {repository} with all available providers. Errors: {"; ".join(errors)}'
|
||||
)
|
||||
raise AuthenticationError(f'Unable to fetch microagents for {repository}')
|
||||
|
||||
# All providers returned empty arrays
|
||||
return []
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Get content of a specific microagent file from a repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in the format 'owner/repo'
|
||||
file_path: Path to the microagent file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
# Try all available providers in order
|
||||
errors = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
result = await service.get_microagent_content(repository, file_path)
|
||||
# If we got content, return it immediately
|
||||
if result:
|
||||
return result
|
||||
# If we got empty content, continue checking other providers
|
||||
logger.debug(
|
||||
f'No content found on {provider} for {repository}/{file_path}, trying other providers'
|
||||
)
|
||||
except ResourceNotFoundError:
|
||||
logger.debug(
|
||||
f'File not found on {provider} for {repository}/{file_path}, trying other providers'
|
||||
)
|
||||
continue
|
||||
except MicroagentParseError as e:
|
||||
# Parsing errors are specific to the provider, add to errors list
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
logger.warning(
|
||||
f'Error parsing microagent content from {provider} for {repository}: {e}'
|
||||
)
|
||||
except Exception as e:
|
||||
# For other errors (auth, rate limit, etc.), add to errors list
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
logger.warning(
|
||||
f'Error fetching microagent content from {provider} for {repository}: {e}'
|
||||
)
|
||||
|
||||
# If all providers failed or returned empty results, raise an error
|
||||
if errors:
|
||||
logger.error(
|
||||
f'Failed to fetch microagent content for {repository} with all available providers. Errors: {"; ".join(errors)}'
|
||||
)
|
||||
|
||||
# All providers returned empty content or file not found
|
||||
raise AuthenticationError(
|
||||
f'Microagent file {file_path} not found in {repository}'
|
||||
)
|
||||
|
||||
async def get_authenticated_git_url(self, repo_name: str) -> str:
|
||||
"""Get an authenticated git URL for a repository.
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from httpx import AsyncClient, HTTPError, HTTPStatusError
|
||||
@ -7,6 +9,8 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.microagent.microagent import BaseMicroagent
|
||||
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
@ -132,6 +136,7 @@ class Repository(BaseModel):
|
||||
owner_type: OwnerType | None = (
|
||||
None # Whether the repository is owned by a user or organization
|
||||
)
|
||||
main_branch: str | None = None # The main/default branch of the repository
|
||||
|
||||
|
||||
class AuthenticationError(ValueError):
|
||||
@ -152,6 +157,18 @@ class RateLimitError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ResourceNotFoundError(ValueError):
|
||||
"""Raised when a requested resource (file, directory, etc.) is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MicroagentParseError(ValueError):
|
||||
"""Raised when there is an error parsing a microagent file."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RequestMethod(Enum):
|
||||
POST = 'post'
|
||||
GET = 'get'
|
||||
@ -171,6 +188,38 @@ class BaseGitService(ABC):
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
...
|
||||
|
||||
async def execute_request(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
@ -185,9 +234,15 @@ class BaseGitService(ABC):
|
||||
|
||||
def handle_http_status_error(
|
||||
self, e: HTTPStatusError
|
||||
) -> AuthenticationError | RateLimitError | UnknownException:
|
||||
) -> (
|
||||
AuthenticationError | RateLimitError | ResourceNotFoundError | UnknownException
|
||||
):
|
||||
if e.response.status_code == 401:
|
||||
return AuthenticationError(f'Invalid {self.provider} token')
|
||||
elif e.response.status_code == 404:
|
||||
return ResourceNotFoundError(
|
||||
f'Resource not found on {self.provider} API: {e}'
|
||||
)
|
||||
elif e.response.status_code == 429:
|
||||
logger.warning(f'Rate limit exceeded on {self.provider} API: {e}')
|
||||
return RateLimitError('GitHub API rate limit exceeded')
|
||||
@ -199,6 +254,184 @@ class BaseGitService(ABC):
|
||||
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
|
||||
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
|
||||
|
||||
def _determine_microagents_path(self, repository_name: str) -> str:
|
||||
"""Determine the microagents directory path based on repository name."""
|
||||
actual_repo_name = repository_name.split('/')[-1]
|
||||
|
||||
# Check for special repository names that use a different structure
|
||||
if actual_repo_name == '.openhands' or actual_repo_name == 'openhands-config':
|
||||
# For repository name ".openhands", scan "microagents" folder
|
||||
return 'microagents'
|
||||
else:
|
||||
# Default behavior: look for .openhands/microagents directory
|
||||
return '.openhands/microagents'
|
||||
|
||||
def _create_microagent_response(
|
||||
self, file_name: str, path: str
|
||||
) -> MicroagentResponse:
|
||||
"""Create a microagent response from basic file information."""
|
||||
# Extract name without extension
|
||||
name = file_name.replace('.md', '').replace('.cursorrules', 'cursorrules')
|
||||
|
||||
return MicroagentResponse(
|
||||
name=name,
|
||||
path=path,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
def _parse_microagent_content(
|
||||
self, content: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Parse microagent content and extract triggers using BaseMicroagent.load.
|
||||
|
||||
Args:
|
||||
content: Raw microagent file content
|
||||
file_path: Path to the file (used for microagent loading)
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
MicroagentParseError: If the microagent file cannot be parsed
|
||||
"""
|
||||
try:
|
||||
# Use BaseMicroagent.load to properly parse the content
|
||||
# Create a temporary path object for the file
|
||||
temp_path = Path(file_path)
|
||||
|
||||
# Load the microagent using the existing infrastructure
|
||||
microagent = BaseMicroagent.load(path=temp_path, file_content=content)
|
||||
|
||||
# Extract triggers from the microagent's metadata
|
||||
triggers = microagent.metadata.triggers
|
||||
|
||||
# Return the MicroagentContentResponse
|
||||
return MicroagentContentResponse(
|
||||
content=microagent.content,
|
||||
path=file_path,
|
||||
triggers=triggers,
|
||||
git_provider=self.provider,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error parsing microagent content for {file_path}: {str(e)}')
|
||||
raise MicroagentParseError(
|
||||
f'Failed to parse microagent file {file_path}: {str(e)}'
|
||||
)
|
||||
|
||||
async def _fetch_cursorrules_content(self, repository: str) -> Any | None:
|
||||
"""Fetch .cursorrules file content from the repository via API.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
|
||||
Returns:
|
||||
Raw API response content if .cursorrules file exists, None otherwise
|
||||
"""
|
||||
cursorrules_url = await self._get_cursorrules_url(repository)
|
||||
cursorrules_response, _ = await self._make_request(cursorrules_url)
|
||||
return cursorrules_response
|
||||
|
||||
async def _check_cursorrules_file(
|
||||
self, repository: str
|
||||
) -> MicroagentResponse | None:
|
||||
"""Check for .cursorrules file in the repository and return microagent response if found.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
|
||||
Returns:
|
||||
MicroagentResponse for .cursorrules file if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
cursorrules_content = await self._fetch_cursorrules_content(repository)
|
||||
if cursorrules_content:
|
||||
return self._create_microagent_response('.cursorrules', '.cursorrules')
|
||||
except ResourceNotFoundError:
|
||||
logger.debug(f'No .cursorrules file found in {repository}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error checking .cursorrules file in {repository}: {e}')
|
||||
|
||||
return None
|
||||
|
||||
async def _process_microagents_directory(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> list[MicroagentResponse]:
|
||||
"""Process microagents directory and return list of microagent responses.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
microagents_path: Path to the microagents directory
|
||||
|
||||
Returns:
|
||||
List of MicroagentResponse objects found in the directory
|
||||
"""
|
||||
microagents = []
|
||||
|
||||
try:
|
||||
directory_url = await self._get_microagents_directory_url(
|
||||
repository, microagents_path
|
||||
)
|
||||
directory_params = self._get_microagents_directory_params(microagents_path)
|
||||
response, _ = await self._make_request(directory_url, directory_params)
|
||||
|
||||
# Handle different response structures
|
||||
items = response
|
||||
if isinstance(response, dict) and 'values' in response:
|
||||
# Bitbucket format
|
||||
items = response['values']
|
||||
elif isinstance(response, dict) and 'nodes' in response:
|
||||
# GraphQL format (if used)
|
||||
items = response['nodes']
|
||||
|
||||
for item in items:
|
||||
if self._is_valid_microagent_file(item):
|
||||
try:
|
||||
file_name = self._get_file_name_from_item(item)
|
||||
file_path = self._get_file_path_from_item(
|
||||
item, microagents_path
|
||||
)
|
||||
microagents.append(
|
||||
self._create_microagent_response(file_name, file_path)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error processing microagent {item.get("name", "unknown")}: {str(e)}'
|
||||
)
|
||||
except ResourceNotFoundError:
|
||||
logger.info(
|
||||
f'No microagents directory found in {repository} at {microagents_path}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching microagents directory: {str(e)}')
|
||||
|
||||
return microagents
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Generic implementation of get_microagents that works across all providers.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository (without content for performance)
|
||||
"""
|
||||
microagents_path = self._determine_microagents_path(repository)
|
||||
microagents = []
|
||||
|
||||
# Step 1: Check for .cursorrules file
|
||||
cursorrules_microagent = await self._check_cursorrules_file(repository)
|
||||
if cursorrules_microagent:
|
||||
microagents.append(cursorrules_microagent)
|
||||
|
||||
# Step 2: Check for microagents directory and process .md files
|
||||
directory_microagents = await self._process_microagents_directory(
|
||||
repository, microagents_path
|
||||
)
|
||||
microagents.extend(directory_microagents)
|
||||
|
||||
return microagents
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
"""Protocol defining the interface for Git service providers"""
|
||||
@ -248,3 +481,17 @@ class GitService(Protocol):
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Get microagents from a repository"""
|
||||
...
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Get content of a specific microagent file
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
"""
|
||||
...
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@ -34,3 +35,25 @@ class MicroagentMetadata(BaseModel):
|
||||
mcp_tools: MCPConfig | None = (
|
||||
None # optional, for microagents that provide additional MCP tools
|
||||
)
|
||||
|
||||
|
||||
class MicroagentResponse(BaseModel):
|
||||
"""Response model for microagents endpoint.
|
||||
|
||||
Note: This model only includes basic metadata that can be determined
|
||||
without parsing microagent content. Use the separate content API
|
||||
to get detailed microagent information.
|
||||
"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MicroagentContentResponse(BaseModel):
|
||||
"""Response model for individual microagent content endpoint."""
|
||||
|
||||
content: str
|
||||
path: str
|
||||
triggers: list[str] = []
|
||||
git_provider: str | None = None
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
@ -19,14 +13,15 @@ from openhands.integrations.provider import (
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent import load_microagents_from_dir
|
||||
from openhands.microagent.types import InputMetadata
|
||||
from openhands.microagent.types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.user_auth import (
|
||||
@ -243,144 +238,6 @@ async def get_repository_branches(
|
||||
)
|
||||
|
||||
|
||||
class MicroagentResponse(BaseModel):
|
||||
"""Response model for microagents endpoint."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
triggers: list[str] = []
|
||||
inputs: list[InputMetadata] = []
|
||||
tools: list[str] = []
|
||||
created_at: datetime
|
||||
git_provider: ProviderType
|
||||
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
|
||||
|
||||
|
||||
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
|
||||
"""Get the creation time of a file from Git history.
|
||||
|
||||
Args:
|
||||
repo_dir: The root directory of the Git repository
|
||||
file_path: The path to the file relative to the repository root
|
||||
|
||||
Returns:
|
||||
datetime: The timestamp when the file was first added to the repository
|
||||
"""
|
||||
try:
|
||||
# Get the relative path from the repository root
|
||||
relative_path = file_path.relative_to(repo_dir)
|
||||
|
||||
# Use git log to get the first commit that added this file
|
||||
# --follow: follow renames and moves
|
||||
# --reverse: show commits in reverse chronological order (oldest first)
|
||||
# --format=%ct: show commit timestamp in Unix format
|
||||
# -1: limit to 1 result (the first commit)
|
||||
cmd = [
|
||||
'git',
|
||||
'log',
|
||||
'--follow',
|
||||
'--reverse',
|
||||
'--format=%ct',
|
||||
'-1',
|
||||
str(relative_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, cwd=repo_dir, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
# Parse Unix timestamp and convert to datetime
|
||||
timestamp = int(result.stdout.strip())
|
||||
return datetime.fromtimestamp(timestamp)
|
||||
else:
|
||||
logger.warning(
|
||||
f'Failed to get creation time for {relative_path}: {result.stderr}'
|
||||
)
|
||||
# Fallback to current time if git log fails
|
||||
return datetime.now()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting creation time for {file_path}: {str(e)}')
|
||||
# Fallback to current time if there's any error
|
||||
return datetime.now()
|
||||
|
||||
|
||||
async def _verify_repository_access(
|
||||
repository_name: str,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None,
|
||||
access_token: SecretStr | None,
|
||||
user_id: str | None,
|
||||
) -> Repository:
|
||||
"""Verify repository access and return repository information.
|
||||
|
||||
Args:
|
||||
repository_name: Repository name in the format 'owner/repo'
|
||||
provider_tokens: Provider tokens for authentication
|
||||
access_token: Access token for external authentication
|
||||
user_id: User ID for authentication
|
||||
|
||||
Returns:
|
||||
Repository object with provider information
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens
|
||||
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
repository = await provider_handler.verify_repo_provider(repository_name)
|
||||
logger.info(
|
||||
f'Detected git provider: {repository.git_provider} for repository: {repository_name}'
|
||||
)
|
||||
return repository
|
||||
|
||||
|
||||
def _clone_repository(remote_url: str, repository_name: str) -> Path:
|
||||
"""Clone repository to temporary directory.
|
||||
|
||||
Args:
|
||||
remote_url: Authenticated git URL for cloning
|
||||
repository_name: Repository name for error messages
|
||||
|
||||
Returns:
|
||||
Path to the cloned repository directory
|
||||
|
||||
Raises:
|
||||
RuntimeError: If cloning fails
|
||||
"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
repo_dir = Path(temp_dir) / 'repo'
|
||||
|
||||
clone_cmd = ['git', 'clone', '--depth', '1', remote_url, str(repo_dir)]
|
||||
|
||||
# Set environment variable to avoid interactive prompts
|
||||
env = os.environ.copy()
|
||||
env['GIT_TERMINAL_PROMPT'] = '0'
|
||||
|
||||
result = subprocess.run(
|
||||
clone_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=30, # 30 second timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Clean up on failure
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
error_msg = f'Failed to clone repository: {result.stderr}'
|
||||
logger.error(f'Failed to clone repository {repository_name}: {result.stderr}')
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
return repo_dir
|
||||
|
||||
|
||||
def _extract_repo_name(repository_name: str) -> str:
|
||||
"""Extract the actual repository name from the full repository path.
|
||||
|
||||
@ -393,99 +250,6 @@ def _extract_repo_name(repository_name: str) -> str:
|
||||
return repository_name.split('/')[-1]
|
||||
|
||||
|
||||
def _process_microagents(
|
||||
repo_dir: Path,
|
||||
repository_name: str,
|
||||
git_provider: ProviderType,
|
||||
) -> list[MicroagentResponse]:
|
||||
"""Process microagents from the cloned repository.
|
||||
|
||||
Args:
|
||||
repo_dir: Path to the cloned repository directory
|
||||
repository_name: Repository name for logging
|
||||
git_provider: Git provider type
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository
|
||||
"""
|
||||
# Extract the actual repository name from the full path
|
||||
actual_repo_name = _extract_repo_name(repository_name)
|
||||
|
||||
# Determine the microagents directory based on git provider and repository name
|
||||
if git_provider != ProviderType.GITLAB and actual_repo_name == '.openhands':
|
||||
# For non-GitLab providers with repository name ".openhands", scan "microagents" folder
|
||||
microagents_dir = repo_dir / 'microagents'
|
||||
elif git_provider == ProviderType.GITLAB and actual_repo_name == 'openhands-config':
|
||||
# For GitLab with repository name "openhands-config", scan "microagents" folder
|
||||
microagents_dir = repo_dir / 'microagents'
|
||||
else:
|
||||
# Default behavior: look for .openhands/microagents directory
|
||||
microagents_dir = repo_dir / '.openhands' / 'microagents'
|
||||
|
||||
if not microagents_dir.exists():
|
||||
logger.info(
|
||||
f'No microagents directory found in {repository_name} at {microagents_dir}'
|
||||
)
|
||||
return []
|
||||
|
||||
# Load microagents from the directory
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# Prepare response
|
||||
microagents = []
|
||||
|
||||
# Add repo microagents
|
||||
for name, r_agent in repo_agents.items():
|
||||
# Get the actual creation time from Git
|
||||
agent_file_path = Path(r_agent.source)
|
||||
created_at = _get_file_creation_time(repo_dir, agent_file_path)
|
||||
|
||||
microagents.append(
|
||||
MicroagentResponse(
|
||||
name=name,
|
||||
type='repo',
|
||||
content=r_agent.content,
|
||||
triggers=[],
|
||||
inputs=r_agent.metadata.inputs,
|
||||
tools=(
|
||||
[server.name for server in r_agent.metadata.mcp_tools.stdio_servers]
|
||||
if r_agent.metadata.mcp_tools
|
||||
else []
|
||||
),
|
||||
created_at=created_at,
|
||||
git_provider=git_provider,
|
||||
path=str(agent_file_path.relative_to(repo_dir)),
|
||||
)
|
||||
)
|
||||
|
||||
# Add knowledge microagents
|
||||
for name, k_agent in knowledge_agents.items():
|
||||
# Get the actual creation time from Git
|
||||
agent_file_path = Path(k_agent.source)
|
||||
created_at = _get_file_creation_time(repo_dir, agent_file_path)
|
||||
|
||||
microagents.append(
|
||||
MicroagentResponse(
|
||||
name=name,
|
||||
type='knowledge',
|
||||
content=k_agent.content,
|
||||
triggers=k_agent.triggers,
|
||||
inputs=k_agent.metadata.inputs,
|
||||
tools=(
|
||||
[server.name for server in k_agent.metadata.mcp_tools.stdio_servers]
|
||||
if k_agent.metadata.mcp_tools
|
||||
else []
|
||||
),
|
||||
created_at=created_at,
|
||||
git_provider=git_provider,
|
||||
path=str(agent_file_path.relative_to(repo_dir)),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f'Found {len(microagents)} microagents in {repository_name}')
|
||||
return microagents
|
||||
|
||||
|
||||
@app.get(
|
||||
'/repository/{repository_name:path}/microagents',
|
||||
response_model=list[MicroagentResponse],
|
||||
@ -503,6 +267,9 @@ async def get_repository_microagents(
|
||||
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
|
||||
- Otherwise: scans ".openhands/microagents" folder
|
||||
|
||||
Note: This API returns microagent metadata without content for performance.
|
||||
Use the separate content API to fetch individual microagent content.
|
||||
|
||||
Args:
|
||||
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
|
||||
provider_tokens: Provider tokens for authentication
|
||||
@ -510,33 +277,21 @@ async def get_repository_microagents(
|
||||
user_id: User ID for authentication
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository's microagents directory
|
||||
List of microagents found in the repository's microagents directory (without content)
|
||||
"""
|
||||
repo_dir = None
|
||||
|
||||
try:
|
||||
# Verify repository access and get provider information
|
||||
repository = await _verify_repository_access(
|
||||
repository_name, provider_tokens, access_token, user_id
|
||||
)
|
||||
|
||||
# Construct authenticated git URL using provider handler
|
||||
# Create provider handler for API authentication
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens
|
||||
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
remote_url = await provider_handler.get_authenticated_git_url(repository_name)
|
||||
|
||||
# Clone repository
|
||||
repo_dir = _clone_repository(remote_url, repository_name)
|
||||
|
||||
# Process microagents
|
||||
microagents = _process_microagents(
|
||||
repo_dir, repository_name, repository.git_provider
|
||||
)
|
||||
# Fetch microagents using the provider handler
|
||||
microagents = await provider_handler.get_microagents(repository_name)
|
||||
|
||||
logger.info(f'Found {len(microagents)} microagents in {repository_name}')
|
||||
return microagents
|
||||
|
||||
except AuthenticationError as e:
|
||||
@ -563,7 +318,76 @@ async def get_repository_microagents(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up temporary directory
|
||||
if repo_dir and repo_dir.parent.exists():
|
||||
shutil.rmtree(repo_dir.parent, ignore_errors=True)
|
||||
|
||||
@app.get(
|
||||
'/repository/{repository_name:path}/microagents/content',
|
||||
response_model=MicroagentContentResponse,
|
||||
)
|
||||
async def get_repository_microagent_content(
|
||||
repository_name: str,
|
||||
file_path: str = Query(
|
||||
..., description='Path to the microagent file within the repository'
|
||||
),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> MicroagentContentResponse | JSONResponse:
|
||||
"""Fetch the content of a specific microagent file from a repository.
|
||||
|
||||
Args:
|
||||
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Query parameter - Path to the microagent file within the repository
|
||||
provider_tokens: Provider tokens for authentication
|
||||
access_token: Access token for external authentication
|
||||
user_id: User ID for authentication
|
||||
|
||||
Returns:
|
||||
Microagent file content and metadata
|
||||
|
||||
Example:
|
||||
GET /api/user/repository/owner/repo/microagents/content?file_path=.openhands/microagents/my-agent.md
|
||||
"""
|
||||
try:
|
||||
# Create provider handler for API authentication
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens
|
||||
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# Fetch file content using the provider handler
|
||||
response = await provider_handler.get_microagent_content(
|
||||
repository_name, file_path
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Retrieved content for microagent {file_path} from {repository_name}'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - Authentication error for user_id: {user_id}, error: {str(e)}'
|
||||
)
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
except RuntimeError as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error fetching microagent content from {repository_name}/{file_path}: {str(e)}',
|
||||
exc_info=True,
|
||||
)
|
||||
return JSONResponse(
|
||||
content=f'Error fetching microagent content: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user