refactor: improve the get microagents API (#9958)

Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
Hiep Le 2025-07-31 11:33:02 +07:00 committed by GitHub
parent c2e860fe92
commit 10ae481b91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1145 additions and 1332 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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