fix(backend): ensure microagents are loaded for V1 conversations (#11772)

Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
This commit is contained in:
Hiep Le 2025-11-19 18:54:08 +07:00 committed by GitHub
parent bede37fdb6
commit 36cf4e161a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1355 additions and 70 deletions

View File

@ -91,14 +91,14 @@ make run
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
components or interface enhancements.
```bash
make start-frontend
```
@ -110,6 +110,7 @@ You can use OpenHands to develop and improve OpenHands itself! This is a powerfu
#### Quick Start
1. **Build and run OpenHands:**
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
@ -117,6 +118,7 @@ You can use OpenHands to develop and improve OpenHands itself! This is a powerfu
```
2. **Access the interface:**
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
@ -199,6 +201,6 @@ Here's a guide to the important documentation files in the repository:
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
- [/microagents/README.md](./microagents/README.md): Information about the microagents architecture and implementation
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@ -73,7 +73,7 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./

View File

@ -40,6 +40,7 @@ export type V1AppConversationStartTaskStatus =
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
| "SETTING_UP_SKILLS"
| "STARTING_CONVERSATION"
| "READY"
| "ERROR";

View File

@ -19,6 +19,7 @@ export function StartTaskStatusIndicator({
case "PREPARING_REPOSITORY":
case "RUNNING_SETUP_SCRIPT":
case "SETTING_UP_GIT_HOOKS":
case "SETTING_UP_SKILLS":
case "STARTING_CONVERSATION":
return "bg-yellow-500 animate-pulse";
default:

View File

@ -440,6 +440,7 @@ export enum I18nKey {
STATUS$STARTING_RUNTIME = "STATUS$STARTING_RUNTIME",
STATUS$SETTING_UP_WORKSPACE = "STATUS$SETTING_UP_WORKSPACE",
STATUS$SETTING_UP_GIT_HOOKS = "STATUS$SETTING_UP_GIT_HOOKS",
STATUS$SETTING_UP_SKILLS = "STATUS$SETTING_UP_SKILLS",
ACCOUNT_SETTINGS_MODAL$DISCONNECT = "ACCOUNT_SETTINGS_MODAL$DISCONNECT",
ACCOUNT_SETTINGS_MODAL$SAVE = "ACCOUNT_SETTINGS_MODAL$SAVE",
ACCOUNT_SETTINGS_MODAL$CLOSE = "ACCOUNT_SETTINGS_MODAL$CLOSE",

View File

@ -7039,6 +7039,22 @@
"ja": "git フックを設定中...",
"uk": "Налаштування git-хуків..."
},
"STATUS$SETTING_UP_SKILLS": {
"en": "Setting up skills...",
"zh-CN": "正在设置技能...",
"zh-TW": "正在設置技能...",
"de": "Fähigkeiten werden eingerichtet...",
"ko-KR": "기술을 설정하는 중...",
"no": "Setter opp ferdigheter...",
"it": "Configurazione delle competenze...",
"pt": "Configurando habilidades...",
"es": "Configurando habilidades...",
"ar": "جاري إعداد المهارات...",
"fr": "Configuration des compétences...",
"tr": "Yetenekler ayarlanıyor...",
"ja": "スキルを設定中...",
"uk": "Налаштування навичок..."
},
"ACCOUNT_SETTINGS_MODAL$DISCONNECT": {
"en": "Disconnect",
"es": "Desconectar",

View File

@ -5,6 +5,7 @@ export type RuntimeStatus =
| "STATUS$RUNTIME_STARTED"
| "STATUS$SETTING_UP_WORKSPACE"
| "STATUS$SETTING_UP_GIT_HOOKS"
| "STATUS$SETTING_UP_SKILLS"
| "STATUS$READY"
| "STATUS$ERROR"
| "STATUS$ERROR_RUNTIME_DISCONNECTED"

View File

@ -118,6 +118,7 @@ class AppConversationStartTaskStatus(Enum):
PREPARING_REPOSITORY = 'PREPARING_REPOSITORY'
RUNNING_SETUP_SCRIPT = 'RUNNING_SETUP_SCRIPT'
SETTING_UP_GIT_HOOKS = 'SETTING_UP_GIT_HOOKS'
SETTING_UP_SKILLS = 'SETTING_UP_SKILLS'
STARTING_CONVERSATION = 'STARTING_CONVERSATION'
READY = 'READY'
ERROR = 'ERROR'

View File

@ -15,7 +15,14 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.app_conversation.skill_loader import (
load_global_skills,
load_repo_skills,
merge_skills,
)
from openhands.app_server.user.user_context import UserContext
from openhands.sdk.context.agent_context import AgentContext
from openhands.sdk.context.skills import load_user_skills
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
@ -24,7 +31,7 @@ PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
@dataclass
class GitAppConversationService(AppConversationService, ABC):
class AppConversationServiceBase(AppConversationService, ABC):
"""App Conversation service which adds git specific functionality.
Sets up repositories and installs hooks"""
@ -32,6 +39,114 @@ class GitAppConversationService(AppConversationService, ABC):
init_git_in_empty_workspace: bool
user_context: UserContext
async def _load_and_merge_all_skills(
self,
remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
working_dir: str,
) -> list:
"""Load skills from all sources and merge them.
This method handles all errors gracefully and will return an empty list
if skill loading fails completely.
Args:
remote_workspace: AsyncRemoteWorkspace for loading repo skills
selected_repository: Repository name or None
working_dir: Working directory path
Returns:
List of merged Skill objects from all sources, or empty list on failure
"""
try:
_logger.debug('Loading skills for V1 conversation')
# Load skills from all sources
global_skills = load_global_skills()
# Load user skills from ~/.openhands/skills/ directory
# Uses the SDK's load_user_skills() function which handles loading from
# ~/.openhands/skills/ and ~/.openhands/microagents/ (for backward compatibility)
try:
user_skills = load_user_skills()
_logger.info(
f'Loaded {len(user_skills)} user skills: {[s.name for s in user_skills]}'
)
except Exception as e:
_logger.warning(f'Failed to load user skills: {str(e)}')
user_skills = []
repo_skills = await load_repo_skills(
remote_workspace, selected_repository, working_dir
)
# Merge all skills (later lists override earlier ones)
all_skills = merge_skills([global_skills, user_skills, repo_skills])
_logger.info(
f'Loaded {len(all_skills)} total skills: {[s.name for s in all_skills]}'
)
return all_skills
except Exception as e:
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
# Return empty list on failure - skills will be loaded again later if needed
return []
def _create_agent_with_skills(self, agent, skills: list):
"""Create or update agent with skills in its context.
Args:
agent: The agent to update
skills: List of Skill objects to add to agent context
Returns:
Updated agent with skills in context
"""
if agent.agent_context:
# Merge with existing context
existing_skills = agent.agent_context.skills
all_skills = merge_skills([skills, existing_skills])
agent = agent.model_copy(
update={
'agent_context': agent.agent_context.model_copy(
update={'skills': all_skills}
)
}
)
else:
# Create new context
agent_context = AgentContext(skills=skills)
agent = agent.model_copy(update={'agent_context': agent_context})
return agent
async def _load_skills_and_update_agent(
self,
agent,
remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
working_dir: str,
):
"""Load all skills and update agent with them.
Args:
agent: The agent to update
remote_workspace: AsyncRemoteWorkspace for loading repo skills
selected_repository: Repository name or None
working_dir: Working directory path
Returns:
Updated agent with skills loaded into context
"""
# Load and merge all skills
all_skills = await self._load_and_merge_all_skills(
remote_workspace, selected_repository, working_dir
)
# Update agent with skills
agent = self._create_agent_with_skills(agent, all_skills)
return agent
async def run_setup_scripts(
self,
task: AppConversationStartTask,
@ -49,6 +164,14 @@ class GitAppConversationService(AppConversationService, ABC):
yield task
await self.maybe_setup_git_hooks(workspace)
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task
await self._load_and_merge_all_skills(
workspace,
task.request.selected_repository,
workspace.working_dir,
)
async def clone_or_init_git_repo(
self,
task: AppConversationStartTask,

View File

@ -34,12 +34,12 @@ from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
AppConversationServiceInjector,
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
)
from openhands.app_server.app_conversation.git_app_conversation_service import (
GitAppConversationService,
)
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
@ -82,7 +82,7 @@ GIT_TOKEN = 'GIT_TOKEN'
@dataclass
class LiveStatusAppConversationService(GitAppConversationService):
class LiveStatusAppConversationService(AppConversationServiceBase):
"""AppConversationService which combines live status info from the sandbox with stored data."""
user_context: UserContext
@ -213,12 +213,12 @@ class LiveStatusAppConversationService(GitAppConversationService):
assert sandbox_spec is not None
# Run setup scripts
workspace = AsyncRemoteWorkspace(
remote_workspace = AsyncRemoteWorkspace(
host=agent_server_url,
api_key=sandbox.session_api_key,
working_dir=sandbox_spec.working_dir,
)
async for updated_task in self.run_setup_scripts(task, workspace):
async for updated_task in self.run_setup_scripts(task, remote_workspace):
yield updated_task
# Build the start request
@ -229,6 +229,8 @@ class LiveStatusAppConversationService(GitAppConversationService):
sandbox_spec.working_dir,
request.agent_type,
request.llm_model,
remote_workspace=remote_workspace,
selected_repository=request.selected_repository,
)
)
@ -515,6 +517,8 @@ class LiveStatusAppConversationService(GitAppConversationService):
working_dir: str,
agent_type: AgentType = AgentType.DEFAULT,
llm_model: str | None = None,
remote_workspace: AsyncRemoteWorkspace | None = None,
selected_repository: str | None = None,
) -> StartConversationRequest:
user = await self.user_context.get_user_info()
@ -565,6 +569,16 @@ class LiveStatusAppConversationService(GitAppConversationService):
user.id, conversation_id, agent
)
# Load and merge all skills if remote_workspace is available
if remote_workspace:
try:
agent = await self._load_skills_and_update_agent(
agent, remote_workspace, selected_repository, working_dir
)
except Exception as e:
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
# Continue without skills - don't fail conversation startup
start_conversation_request = StartConversationRequest(
conversation_id=conversation_id,
agent=agent,

View File

@ -0,0 +1,331 @@
"""Utilities for loading skills for V1 conversations.
This module provides functions to load skills from various sources:
- Global skills from OpenHands/skills/
- User skills from ~/.openhands/skills/
- Repository-level skills from the workspace
All skills are used in V1 conversations.
"""
import logging
import os
from pathlib import Path
import openhands
from openhands.sdk.context.skills import Skill
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
# Path to global skills directory
GLOBAL_SKILLS_DIR = os.path.join(
os.path.dirname(os.path.dirname(openhands.__file__)),
'skills',
)
def _find_and_load_global_skill_files(skill_dir: Path) -> list[Skill]:
"""Find and load all .md files from the global skills directory.
Args:
skill_dir: Path to the global skills directory
Returns:
List of Skill objects loaded from the files (excluding README.md)
"""
skills = []
try:
# Find all .md files in the directory (excluding README.md)
md_files = [f for f in skill_dir.glob('*.md') if f.name.lower() != 'readme.md']
# Load skills from the found files
for file_path in md_files:
try:
skill = Skill.load(file_path, skill_dir)
skills.append(skill)
_logger.debug(f'Loaded global skill: {skill.name} from {file_path}')
except Exception as e:
_logger.warning(
f'Failed to load global skill from {file_path}: {str(e)}'
)
except Exception as e:
_logger.debug(f'Failed to find global skill files: {str(e)}')
return skills
def load_global_skills() -> list[Skill]:
"""Load global skills from OpenHands/skills/ directory.
Returns:
List of Skill objects loaded from global skills directory.
Returns empty list if directory doesn't exist or on errors.
"""
skill_dir = Path(GLOBAL_SKILLS_DIR)
# Check if directory exists
if not skill_dir.exists():
_logger.debug(f'Global skills directory does not exist: {skill_dir}')
return []
try:
_logger.info(f'Loading global skills from {skill_dir}')
# Find and load all .md files from the directory
skills = _find_and_load_global_skill_files(skill_dir)
_logger.info(f'Loaded {len(skills)} global skills: {[s.name for s in skills]}')
return skills
except Exception as e:
_logger.warning(f'Failed to load global skills: {str(e)}')
return []
def _determine_repo_root(working_dir: str, selected_repository: str | None) -> str:
"""Determine the repository root directory.
Args:
working_dir: Base working directory path
selected_repository: Repository name (e.g., 'owner/repo') or None
Returns:
Path to the repository root directory
"""
if selected_repository:
repo_name = selected_repository.split('/')[-1]
return f'{working_dir}/{repo_name}'
return working_dir
async def _read_file_from_workspace(
workspace: AsyncRemoteWorkspace, file_path: str, working_dir: str
) -> str | None:
"""Read file content from remote workspace.
Args:
workspace: AsyncRemoteWorkspace to execute commands
file_path: Path to the file to read
working_dir: Working directory for command execution
Returns:
File content as string, or None if file doesn't exist or read fails
"""
try:
result = await workspace.execute_command(
f'cat {file_path}', cwd=working_dir, timeout=10.0
)
if result.exit_code == 0 and result.stdout.strip():
return result.stdout
return None
except Exception as e:
_logger.debug(f'Failed to read file {file_path}: {str(e)}')
return None
async def _load_special_files(
workspace: AsyncRemoteWorkspace, repo_root: str, working_dir: str
) -> list[Skill]:
"""Load special skill files from repository root.
Loads: .cursorrules, agents.md, agent.md
Args:
workspace: AsyncRemoteWorkspace to execute commands
repo_root: Path to repository root directory
working_dir: Working directory for command execution
Returns:
List of Skill objects loaded from special files
"""
skills = []
special_files = ['.cursorrules', 'agents.md', 'agent.md']
for filename in special_files:
file_path = f'{repo_root}/{filename}'
content = await _read_file_from_workspace(workspace, file_path, working_dir)
if content:
try:
# Use simple string path to avoid Path filesystem operations
skill = Skill.load(path=filename, skill_dir=None, file_content=content)
skills.append(skill)
_logger.debug(f'Loaded special file skill: {skill.name}')
except Exception as e:
_logger.warning(f'Failed to create skill from {filename}: {str(e)}')
return skills
async def _find_and_load_skill_md_files(
workspace: AsyncRemoteWorkspace, skill_dir: str, working_dir: str
) -> list[Skill]:
"""Find and load all .md files from a skills directory in the workspace.
Args:
workspace: AsyncRemoteWorkspace to execute commands
skill_dir: Path to skills directory
working_dir: Working directory for command execution
Returns:
List of Skill objects loaded from the files (excluding README.md)
"""
skills = []
try:
# Find all .md files in the directory
result = await workspace.execute_command(
f"find {skill_dir} -type f -name '*.md' 2>/dev/null || true",
cwd=working_dir,
timeout=10.0,
)
if result.exit_code == 0 and result.stdout.strip():
file_paths = [
f.strip()
for f in result.stdout.strip().split('\n')
if f.strip() and 'README.md' not in f
]
# Load skills from the found files
for file_path in file_paths:
content = await _read_file_from_workspace(
workspace, file_path, working_dir
)
if content:
# Calculate relative path for skill name
rel_path = file_path.replace(f'{skill_dir}/', '')
try:
# Use simple string path to avoid Path filesystem operations
skill = Skill.load(
path=rel_path, skill_dir=None, file_content=content
)
skills.append(skill)
_logger.debug(f'Loaded repo skill: {skill.name}')
except Exception as e:
_logger.warning(
f'Failed to create skill from {rel_path}: {str(e)}'
)
except Exception as e:
_logger.debug(f'Failed to find skill files in {skill_dir}: {str(e)}')
return skills
def _merge_repo_skills_with_precedence(
special_skills: list[Skill],
skills_dir_skills: list[Skill],
microagents_dir_skills: list[Skill],
) -> list[Skill]:
"""Merge repository skills with precedence order.
Precedence (highest to lowest):
1. Special files (repo root)
2. .openhands/skills/ directory
3. .openhands/microagents/ directory (backward compatibility)
Args:
special_skills: Skills from special files in repo root
skills_dir_skills: Skills from .openhands/skills/ directory
microagents_dir_skills: Skills from .openhands/microagents/ directory
Returns:
Deduplicated list of skills with proper precedence
"""
# Use a dict to deduplicate by name, with earlier sources taking precedence
skills_by_name = {}
for skill in special_skills + skills_dir_skills + microagents_dir_skills:
# Only add if not already present (earlier sources win)
if skill.name not in skills_by_name:
skills_by_name[skill.name] = skill
return list(skills_by_name.values())
async def load_repo_skills(
workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
working_dir: str,
) -> list[Skill]:
"""Load repository-level skills from the workspace.
Loads skills from:
1. Special files in repo root: .cursorrules, agents.md, agent.md
2. .md files in .openhands/skills/ directory (preferred)
3. .md files in .openhands/microagents/ directory (for backward compatibility)
Args:
workspace: AsyncRemoteWorkspace to execute commands in the sandbox
selected_repository: Repository name (e.g., 'owner/repo') or None
working_dir: Working directory path
Returns:
List of Skill objects loaded from repository.
Returns empty list on errors.
"""
try:
# Determine repository root directory
repo_root = _determine_repo_root(working_dir, selected_repository)
_logger.info(f'Loading repo skills from {repo_root}')
# Load special files from repo root
special_skills = await _load_special_files(workspace, repo_root, working_dir)
# Load .md files from .openhands/skills/ directory (preferred)
skills_dir = f'{repo_root}/.openhands/skills'
skills_dir_skills = await _find_and_load_skill_md_files(
workspace, skills_dir, working_dir
)
# Load .md files from .openhands/microagents/ directory (backward compatibility)
microagents_dir = f'{repo_root}/.openhands/microagents'
microagents_dir_skills = await _find_and_load_skill_md_files(
workspace, microagents_dir, working_dir
)
# Merge all loaded skills with proper precedence
all_skills = _merge_repo_skills_with_precedence(
special_skills, skills_dir_skills, microagents_dir_skills
)
_logger.info(
f'Loaded {len(all_skills)} repo skills: {[s.name for s in all_skills]}'
)
return all_skills
except Exception as e:
_logger.warning(f'Failed to load repo skills: {str(e)}')
return []
def merge_skills(skill_lists: list[list[Skill]]) -> list[Skill]:
"""Merge multiple skill lists, avoiding duplicates by name.
Later lists take precedence over earlier lists for duplicate names.
Args:
skill_lists: List of skill lists to merge
Returns:
Deduplicated list of skills with later lists overriding earlier ones
"""
skills_by_name = {}
for skill_list in skill_lists:
for skill in skill_list:
if skill.name in skills_by_name:
_logger.debug(
f'Overriding skill "{skill.name}" from earlier source with later source'
)
skills_by_name[skill.name] = skill
result = list(skills_by_name.values())
_logger.debug(f'Merged skills: {[s.name for s in result]}')
return result

View File

@ -32,7 +32,7 @@ from openhands.utils.prompt import (
GLOBAL_MICROAGENTS_DIR = os.path.join(
os.path.dirname(os.path.dirname(openhands.__file__)),
'microagents',
'skills',
)
USER_MICROAGENTS_DIR = Path.home() / '.openhands' / 'microagents'
@ -77,7 +77,7 @@ class Memory:
self.conversation_instructions: ConversationInstructions | None = None
# Load global microagents (Knowledge + Repo)
# from typically OpenHands/microagents (i.e., the PUBLIC microagents)
# from typically OpenHands/skills (i.e., the PUBLIC microagents)
self._load_global_microagents()
# Load user microagents from ~/.openhands/microagents/
@ -172,33 +172,50 @@ class Memory:
):
obs = RecallObservation(
recall_type=RecallType.WORKSPACE_CONTEXT,
repo_name=self.repository_info.repo_name
if self.repository_info and self.repository_info.repo_name is not None
else '',
repo_directory=self.repository_info.repo_directory
if self.repository_info
and self.repository_info.repo_directory is not None
else '',
repo_branch=self.repository_info.branch_name
if self.repository_info and self.repository_info.branch_name is not None
else '',
repo_name=(
self.repository_info.repo_name
if self.repository_info
and self.repository_info.repo_name is not None
else ''
),
repo_directory=(
self.repository_info.repo_directory
if self.repository_info
and self.repository_info.repo_directory is not None
else ''
),
repo_branch=(
self.repository_info.branch_name
if self.repository_info
and self.repository_info.branch_name is not None
else ''
),
repo_instructions=repo_instructions if repo_instructions else '',
runtime_hosts=self.runtime_info.available_hosts
if self.runtime_info and self.runtime_info.available_hosts is not None
else {},
additional_agent_instructions=self.runtime_info.additional_agent_instructions
if self.runtime_info
and self.runtime_info.additional_agent_instructions is not None
else '',
runtime_hosts=(
self.runtime_info.available_hosts
if self.runtime_info
and self.runtime_info.available_hosts is not None
else {}
),
additional_agent_instructions=(
self.runtime_info.additional_agent_instructions
if self.runtime_info
and self.runtime_info.additional_agent_instructions is not None
else ''
),
microagent_knowledge=microagent_knowledge,
content='Added workspace context',
date=self.runtime_info.date if self.runtime_info is not None else '',
custom_secrets_descriptions=self.runtime_info.custom_secrets_descriptions
if self.runtime_info is not None
else {},
conversation_instructions=self.conversation_instructions.content
if self.conversation_instructions is not None
else '',
custom_secrets_descriptions=(
self.runtime_info.custom_secrets_descriptions
if self.runtime_info is not None
else {}
),
conversation_instructions=(
self.conversation_instructions.content
if self.conversation_instructions is not None
else ''
),
working_dir=self.runtime_info.working_dir if self.runtime_info else '',
)
return obs

View File

@ -247,9 +247,9 @@ def build_runtime_image_in_folder(
lock_tag=lock_tag,
# Only tag the versioned image if we are building from scratch.
# This avoids too much layers when you lay one image on top of another multiple times
versioned_tag=versioned_tag
if build_from == BuildFromImageType.SCRATCH
else None,
versioned_tag=(
versioned_tag if build_from == BuildFromImageType.SCRATCH else None
),
platform=platform,
extra_build_args=extra_build_args,
)
@ -282,10 +282,8 @@ def prep_build_folder(
),
)
# Copy the 'microagents' directory (Microagents)
shutil.copytree(
Path(project_root, 'microagents'), Path(build_folder, 'code', 'microagents')
)
# Copy the 'skills' directory (Skills)
shutil.copytree(Path(project_root, 'skills'), Path(build_folder, 'code', 'skills'))
# Copy pyproject.toml and poetry.lock files
for file in ['pyproject.toml', 'poetry.lock']:

View File

@ -336,8 +336,8 @@ RUN \
# ================================================================
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
RUN if [ -d /openhands/code/microagents ]; then rm -rf /openhands/code/microagents; fi
COPY --chown=openhands:openhands ./code/microagents /openhands/code/microagents
RUN if [ -d /openhands/code/skills ]; then rm -rf /openhands/code/skills; fi
COPY --chown=openhands:openhands ./code/skills /openhands/code/skills
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
chown -R openhands:openhands /openhands/code

View File

@ -20,7 +20,7 @@ packages = [
]
include = [
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
"microagents/**/*",
"skills/**/*",
]
build = "build_vscode.py" # Build VSCode extension during Poetry build

View File

@ -1,82 +1,107 @@
# OpenHands Microagents
# OpenHands Skills
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They help developers by providing expert guidance, automating common tasks, and ensuring consistent practices across projects. Each microagent is designed to excel in a specific area, from Git operations to code review processes.
Skills are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They help developers by providing expert guidance, automating common tasks, and ensuring consistent practices across projects. Each skill is designed to excel in a specific area, from Git operations to code review processes.
## Sources of Microagents
## Terminology Note
OpenHands loads microagents from two sources:
**Version 0 (V0)**: The term "microagents" continues to be used for V0 conversations. V0 is the current stable version of OpenHands.
**Version 1 (V1)**: The term "skills" is used for V1 conversations. V1 UI and app server have not yet been released, but the codebase has been updated to use "skills" terminology in preparation for the V1 release.
This directory (`OpenHands/skills/`) contains shareable skills that will be used in V1 conversations. For V0 conversations, the system continues to use microagents from the same underlying files.
## Sources of Skills/Microagents
OpenHands loads skills (V1) or microagents (V0) from two sources:
### 1. Shareable Skills/Microagents (Public)
This directory (`OpenHands/skills/`) contains shareable skills (V1) or microagents (V0) that are:
### 1. Shareable Microagents (Public)
This directory (`OpenHands/microagents/`) contains shareable microagents that are:
- Available to all OpenHands users
- Maintained in the OpenHands repository
- Perfect for reusable knowledge and common workflows
- Used as "skills" in V1 conversations and "microagents" in V0 conversations
Directory structure:
```
OpenHands/microagents/
OpenHands/skills/
├── # Keyword-triggered expertise
│ ├── git.md # Git operations
│ ├── testing.md # Testing practices
│ └── docker.md # Docker guidelines
└── # These microagents are always loaded
└── # These skills/microagents are always loaded
├── pr_review.md # PR review process
├── bug_fix.md # Bug fixing workflow
└── feature.md # Feature implementation
```
### 2. Repository Instructions (Private)
Each repository can have its own instructions in `.openhands/microagents/repo.md`. These instructions are:
Each repository can have its own instructions in `.openhands/microagents/` (V0) or `.openhands/skills/` (V1). These instructions are:
- Private to that repository
- Automatically loaded when working with that repository
- Perfect for repository-specific guidelines and team practices
- V1 supports both `.openhands/skills/` (preferred) and `.openhands/microagents/` (backward compatibility)
Example repository structure:
```
your-repository/
└── .openhands/
└── microagents/
├── skills/ # V1: Preferred location for repository-specific skills
│ └── repo.md # Repository-specific instructions
│ └── ... # Private skills that are only available inside this
└── microagents/ # V0: Current location (also supported in V1 for backward compatibility)
└── repo.md # Repository-specific instructions
└── ... # Private micro-agents that are only available inside this repo
```
## Loading Order
When OpenHands works with a repository, it:
1. Loads repository-specific instructions from `.openhands/microagents/repo.md` if present
1. Loads repository-specific instructions from `.openhands/microagents/repo.md` (V0) or `.openhands/skills/` (V1) if present
2. Loads relevant knowledge agents based on keywords in conversations
## Types of Microagents
**Note**: V1 also supports loading from `.openhands/microagents/` for backward compatibility.
Most microagents use markdown files with YAML frontmatter. For repository agents (repo.md), the frontmatter is optional - if not provided, the file will be loaded with default settings as a repository agent.
## Types of Skills/Microagents
Most skills/microagents use markdown files with YAML frontmatter. For repository agents (repo.md), the frontmatter is optional - if not provided, the file will be loaded with default settings as a repository agent.
### 1. Knowledge Agents
Knowledge agents provide specialized expertise that's triggered by keywords in conversations. They help with:
- Language best practices
- Framework guidelines
- Common patterns
- Tool usage
Key characteristics:
- **Trigger-based**: Activated by specific keywords in conversations
- **Context-aware**: Provide relevant advice based on file types and content
- **Reusable**: Knowledge can be applied across multiple projects
- **Versioned**: Support multiple versions of tools/frameworks
You can see an example of a knowledge-based agent in [OpenHands's github microagent](https://github.com/OpenHands/OpenHands/tree/main/microagents/github.md).
You can see an example of a knowledge-based agent in [OpenHands's github skill](https://github.com/OpenHands/OpenHands/tree/main/skills/github.md).
### 2. Repository Agents
Repository agents provide repository-specific knowledge and guidelines. They are:
- Loaded from `.openhands/microagents/repo.md`
- Loaded from `.openhands/microagents/repo.md` (V0) or `.openhands/skills/` directory (V1)
- V1 also supports `.openhands/microagents/` for backward compatibility
- Specific to individual repositories
- Automatically activated for their repository
- Perfect for team practices and project conventions
Key features:
- **Project-specific**: Contains guidelines unique to the repository
- **Team-focused**: Enforces team conventions and practices
- **Always active**: Automatically loaded for the repository
@ -84,18 +109,17 @@ Key features:
You can see an example of a repo agent in [the agent for the OpenHands repo itself](https://github.com/OpenHands/OpenHands/blob/main/.openhands/microagents/repo.md).
## Contributing
### When to Contribute
1. **Knowledge Agents** - When you have:
- Language/framework best practices
- Tool usage patterns
- Common problem solutions
- General development guidelines
2. **Repository Agents** - When you need:
- Project-specific guidelines
- Team conventions and practices
@ -105,13 +129,13 @@ You can see an example of a repo agent in [the agent for the OpenHands repo itse
### Best Practices
1. **For Knowledge Agents**:
- Choose distinctive triggers
- Focus on one area of expertise
- Include practical examples
- Use file patterns when relevant
- Keep knowledge general and reusable
2. **For Repository Agents**:
- Document clear setup instructions
- Include repository structure details
@ -126,12 +150,11 @@ You can see an example of a repo agent in [the agent for the OpenHands repo itse
### Submission Process
1. Create your agent file in the appropriate directory:
- `microagents/` for expertise (public, shareable)
- Note: Repository-specific agents should remain in their respective repositories' `.openhands/microagents/` directory
- `skills/` for expertise (public, shareable)
- Note: Repository-specific agents should remain in their respective repositories' `.openhands/skills/` (V1) or `.openhands/microagents/` (V0) directory
2. Test thoroughly
3. Submit a pull request to OpenHands
## License
All microagents are subject to the same license as OpenHands. See the root LICENSE file for details.
All skills/microagents are subject to the same license as OpenHands. See the root LICENSE file for details.

View File

@ -37,4 +37,4 @@ When creating a new microagent:
For detailed information, see:
- [Microagents Overview](https://docs.all-hands.dev/usage/prompting/microagents-overview)
- [Example GitHub Microagent](https://github.com/OpenHands/OpenHands/blob/main/microagents/github.md)
- [Example GitHub Skill](https://github.com/OpenHands/OpenHands/blob/main/skills/github.md)

View File

@ -0,0 +1,756 @@
"""Tests for skill_loader module.
This module tests the loading of skills from various sources
(global, user, and repository-level) into SDK Skill objects for V1 conversations.
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openhands.app_server.app_conversation.skill_loader import (
_determine_repo_root,
_find_and_load_global_skill_files,
_find_and_load_skill_md_files,
_load_special_files,
_read_file_from_workspace,
load_global_skills,
load_repo_skills,
merge_skills,
)
# ===== Test Fixtures =====
@pytest.fixture
def mock_skill():
"""Create a mock Skill object."""
skill = Mock()
skill.name = 'test_skill'
skill.content = 'Test content'
return skill
@pytest.fixture
def mock_skills_list():
"""Create a list of mock Skill objects."""
skills = []
for i in range(3):
skill = Mock()
skill.name = f'skill_{i}'
skill.content = f'Content {i}'
skills.append(skill)
return skills
@pytest.fixture
def mock_async_remote_workspace():
"""Create a mock AsyncRemoteWorkspace."""
workspace = AsyncMock()
return workspace
@pytest.fixture
def temp_skills_dir():
"""Create a temporary directory with test skill files."""
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
# Create test skill files
test_skill = """---
name: test_skill
triggers:
- test
- testing
---
# Test Skill
This is a test skill for testing purposes.
"""
(root / 'test_skill.md').write_text(test_skill)
another_skill = """---
name: another_skill
---
# Another Skill
Another test skill.
"""
(root / 'another_skill.md').write_text(another_skill)
# Create README.md which should be ignored
(root / 'README.md').write_text('# README\n\nThis should be ignored.')
yield root
@pytest.fixture
def command_result_success():
"""Create a successful command result."""
result = Mock()
result.exit_code = 0
result.stdout = 'test output'
return result
@pytest.fixture
def command_result_failure():
"""Create a failed command result."""
result = Mock()
result.exit_code = 1
result.stdout = ''
return result
# ===== Tests for Helper Functions =====
class TestDetermineRepoRoot:
"""Test _determine_repo_root helper function."""
def test_with_selected_repository(self):
"""Test determining repo root with selected repository."""
result = _determine_repo_root('/workspace/project', 'owner/repo-name')
assert result == '/workspace/project/repo-name'
def test_without_selected_repository(self):
"""Test determining repo root without selected repository."""
result = _determine_repo_root('/workspace/project', None)
assert result == '/workspace/project'
def test_with_complex_repository_name(self):
"""Test with complex repository name."""
result = _determine_repo_root('/workspace', 'org-name/complex-repo-123')
assert result == '/workspace/complex-repo-123'
class TestReadFileFromWorkspace:
"""Test _read_file_from_workspace helper function."""
@pytest.mark.asyncio
async def test_successful_read(
self, mock_async_remote_workspace, command_result_success
):
"""Test successfully reading a file from workspace."""
command_result_success.stdout = 'file content\n'
mock_async_remote_workspace.execute_command.return_value = (
command_result_success
)
result = await _read_file_from_workspace(
mock_async_remote_workspace, '/path/to/file.md', '/workspace'
)
assert result == 'file content\n'
mock_async_remote_workspace.execute_command.assert_called_once_with(
'cat /path/to/file.md', cwd='/workspace', timeout=10.0
)
@pytest.mark.asyncio
async def test_file_not_found(
self, mock_async_remote_workspace, command_result_failure
):
"""Test reading a non-existent file."""
mock_async_remote_workspace.execute_command.return_value = (
command_result_failure
)
result = await _read_file_from_workspace(
mock_async_remote_workspace, '/nonexistent/file.md', '/workspace'
)
assert result is None
@pytest.mark.asyncio
async def test_empty_file(self, mock_async_remote_workspace):
"""Test reading an empty file."""
result_obj = Mock()
result_obj.exit_code = 0
result_obj.stdout = ' ' # Only whitespace
mock_async_remote_workspace.execute_command.return_value = result_obj
result = await _read_file_from_workspace(
mock_async_remote_workspace, '/empty/file.md', '/workspace'
)
assert result is None
@pytest.mark.asyncio
async def test_command_exception(self, mock_async_remote_workspace):
"""Test handling exception during file read."""
mock_async_remote_workspace.execute_command.side_effect = Exception(
'Connection error'
)
result = await _read_file_from_workspace(
mock_async_remote_workspace, '/path/to/file.md', '/workspace'
)
assert result is None
class TestLoadSpecialFiles:
"""Test _load_special_files helper function."""
@pytest.mark.asyncio
@patch(
'openhands.app_server.app_conversation.skill_loader._read_file_from_workspace'
)
@patch('openhands.app_server.app_conversation.skill_loader.Skill')
async def test_load_all_special_files(
self,
mock_skill_class,
mock_read_file,
mock_async_remote_workspace,
mock_skills_list,
):
"""Test loading all special files successfully."""
# Mock reading files - return content for each special file
mock_read_file.side_effect = [
'cursorrules content',
'agents.md content',
'agent.md content',
]
# Mock skill creation
mock_skill_class.load.side_effect = mock_skills_list
result = await _load_special_files(
mock_async_remote_workspace, '/repo', '/workspace'
)
assert len(result) == 3
assert result == mock_skills_list
assert mock_read_file.call_count == 3
assert mock_skill_class.load.call_count == 3
@pytest.mark.asyncio
@patch(
'openhands.app_server.app_conversation.skill_loader._read_file_from_workspace'
)
@patch('openhands.app_server.app_conversation.skill_loader.Skill')
async def test_load_partial_special_files(
self, mock_skill_class, mock_read_file, mock_async_remote_workspace, mock_skill
):
"""Test loading when only some special files exist."""
# Only .cursorrules exists
mock_read_file.side_effect = ['cursorrules content', None, None]
mock_skill_class.load.return_value = mock_skill
result = await _load_special_files(
mock_async_remote_workspace, '/repo', '/workspace'
)
assert len(result) == 1
assert result[0] == mock_skill
assert mock_read_file.call_count == 3
assert mock_skill_class.load.call_count == 1
@pytest.mark.asyncio
@patch(
'openhands.app_server.app_conversation.skill_loader._read_file_from_workspace'
)
async def test_load_no_special_files(
self, mock_read_file, mock_async_remote_workspace
):
"""Test when no special files exist."""
mock_read_file.return_value = None
result = await _load_special_files(
mock_async_remote_workspace, '/repo', '/workspace'
)
assert len(result) == 0
class TestFindAndLoadSkillMdFiles:
"""Test _find_and_load_skill_md_files helper function."""
@pytest.mark.asyncio
@patch(
'openhands.app_server.app_conversation.skill_loader._read_file_from_workspace'
)
@patch('openhands.app_server.app_conversation.skill_loader.Skill')
async def test_find_and_load_files_success(
self,
mock_skill_class,
mock_read_file,
mock_async_remote_workspace,
mock_skills_list,
):
"""Test successfully finding and loading skill .md files."""
result_obj = Mock()
result_obj.exit_code = 0
result_obj.stdout = (
'/repo/.openhands/skills/test1.md\n/repo/.openhands/skills/test2.md\n'
)
mock_async_remote_workspace.execute_command.return_value = result_obj
mock_read_file.side_effect = ['content1', 'content2']
mock_skill_class.load.side_effect = mock_skills_list[:2]
result = await _find_and_load_skill_md_files(
mock_async_remote_workspace, '/repo/.openhands/skills', '/workspace'
)
assert len(result) == 2
assert result == mock_skills_list[:2]
# Verify relative paths are used
assert mock_skill_class.load.call_args_list[0][1]['path'] == 'test1.md'
assert mock_skill_class.load.call_args_list[1][1]['path'] == 'test2.md'
@pytest.mark.asyncio
@patch(
'openhands.app_server.app_conversation.skill_loader._read_file_from_workspace'
)
@patch('openhands.app_server.app_conversation.skill_loader.Skill')
async def test_find_and_load_excludes_readme(
self, mock_skill_class, mock_read_file, mock_async_remote_workspace, mock_skill
):
"""Test that README.md files are excluded."""
result_obj = Mock()
result_obj.exit_code = 0
result_obj.stdout = (
'/repo/.openhands/skills/test.md\n/repo/.openhands/skills/README.md\n'
)
mock_async_remote_workspace.execute_command.return_value = result_obj
mock_read_file.return_value = 'content'
mock_skill_class.load.return_value = mock_skill
result = await _find_and_load_skill_md_files(
mock_async_remote_workspace, '/repo/.openhands/skills', '/workspace'
)
assert len(result) == 1
assert result[0] == mock_skill
# Verify README.md was not processed
assert mock_read_file.call_count == 1
@pytest.mark.asyncio
async def test_find_and_load_no_results(
self, mock_async_remote_workspace, command_result_failure
):
"""Test when no files are found."""
mock_async_remote_workspace.execute_command.return_value = (
command_result_failure
)
result = await _find_and_load_skill_md_files(
mock_async_remote_workspace, '/nonexistent', '/workspace'
)
assert len(result) == 0
@pytest.mark.asyncio
async def test_find_and_load_exception(self, mock_async_remote_workspace):
"""Test handling exception during file search."""
mock_async_remote_workspace.execute_command.side_effect = Exception(
'Command error'
)
result = await _find_and_load_skill_md_files(
mock_async_remote_workspace, '/repo/.openhands/skills', '/workspace'
)
assert len(result) == 0
@pytest.mark.asyncio
@patch(
'openhands.app_server.app_conversation.skill_loader._read_file_from_workspace'
)
async def test_find_and_load_some_missing(
self, mock_read_file, mock_async_remote_workspace
):
"""Test loading when some files fail to read."""
result_obj = Mock()
result_obj.exit_code = 0
result_obj.stdout = (
'/repo/.openhands/skills/test1.md\n/repo/.openhands/skills/missing.md\n'
)
mock_async_remote_workspace.execute_command.return_value = result_obj
mock_read_file.side_effect = ['content1', None]
with patch(
'openhands.app_server.app_conversation.skill_loader.Skill'
) as mock_skill_class:
mock_skill = Mock()
mock_skill_class.load.return_value = mock_skill
result = await _find_and_load_skill_md_files(
mock_async_remote_workspace,
'/repo/.openhands/skills',
'/workspace',
)
assert len(result) == 1
assert mock_skill_class.load.call_count == 1
class TestFindAndLoadGlobalSkillFiles:
"""Test _find_and_load_global_skill_files helper function."""
@patch('openhands.app_server.app_conversation.skill_loader.Skill')
def test_find_and_load_global_files_success(
self, mock_skill_class, temp_skills_dir, mock_skills_list
):
"""Test successfully finding and loading global skill files."""
file_paths = list(temp_skills_dir.glob('*.md'))
file_paths = [f for f in file_paths if f.name.lower() != 'readme.md']
mock_skill_class.load.side_effect = mock_skills_list[: len(file_paths)]
result = _find_and_load_global_skill_files(temp_skills_dir)
# Should find and load .md files but not README.md
assert len(result) == len(file_paths)
assert mock_skill_class.load.call_count == len(file_paths)
skill_names = [s.name for s in result]
assert len(skill_names) == len(file_paths)
@patch('openhands.app_server.app_conversation.skill_loader.Skill')
def test_find_and_load_global_files_with_errors(
self, mock_skill_class, temp_skills_dir, mock_skill
):
"""Test loading when some files fail to parse."""
file_paths = list(temp_skills_dir.glob('*.md'))
file_paths = [f for f in file_paths if f.name.lower() != 'readme.md']
# First file succeeds, second file fails
mock_skill_class.load.side_effect = [mock_skill, Exception('Parse error')]
result = _find_and_load_global_skill_files(temp_skills_dir)
assert len(result) == 1
assert result[0] == mock_skill
def test_find_and_load_global_files_empty_dir(self, tmp_path):
"""Test finding and loading files in empty directory."""
result = _find_and_load_global_skill_files(tmp_path)
assert len(result) == 0
def test_find_and_load_global_files_nonexistent_dir(self):
"""Test finding and loading files in non-existent directory."""
nonexistent = Path('/nonexistent/path')
result = _find_and_load_global_skill_files(nonexistent)
assert len(result) == 0
# ===== Tests for Main Loader Functions =====
class TestLoadGlobalSkills:
"""Test load_global_skills main function."""
@patch('openhands.app_server.app_conversation.skill_loader.Path')
@patch(
'openhands.app_server.app_conversation.skill_loader._find_and_load_global_skill_files'
)
def test_load_global_skills_success(
self,
mock_find_and_load,
mock_path_class,
temp_skills_dir,
mock_skills_list,
):
"""Test successfully loading global skills."""
mock_path_obj = MagicMock()
mock_path_obj.exists.return_value = True
mock_path_class.return_value = mock_path_obj
mock_find_and_load.return_value = mock_skills_list
result = load_global_skills()
assert len(result) == len(mock_skills_list)
assert result == mock_skills_list
@patch('openhands.app_server.app_conversation.skill_loader.Path')
def test_load_global_skills_dir_not_exists(self, mock_path_class):
"""Test when global skills directory doesn't exist."""
mock_path_obj = MagicMock()
mock_path_obj.exists.return_value = False
mock_path_class.return_value = mock_path_obj
result = load_global_skills()
assert len(result) == 0
@patch('openhands.app_server.app_conversation.skill_loader.Path')
@patch(
'openhands.app_server.app_conversation.skill_loader._find_and_load_global_skill_files'
)
def test_load_global_skills_exception(self, mock_find_and_load, mock_path_class):
"""Test handling exception during global skill loading."""
mock_path_obj = MagicMock()
mock_path_obj.exists.return_value = True
mock_path_class.return_value = mock_path_obj
mock_find_and_load.side_effect = Exception('File system error')
result = load_global_skills()
assert len(result) == 0
class TestLoadRepoSkills:
"""Test load_repo_skills main function."""
@pytest.mark.asyncio
@patch('openhands.app_server.app_conversation.skill_loader._load_special_files')
@patch(
'openhands.app_server.app_conversation.skill_loader._find_and_load_skill_md_files'
)
async def test_load_repo_skills_success(
self,
mock_find_and_load,
mock_load_special,
mock_async_remote_workspace,
mock_skills_list,
):
"""Test successfully loading repo skills."""
special_skills = [mock_skills_list[0]]
skills_dir_skills = [mock_skills_list[1]]
microagents_dir_skills = [mock_skills_list[2]]
mock_load_special.return_value = special_skills
# Mock loading from both directories
mock_find_and_load.side_effect = [skills_dir_skills, microagents_dir_skills]
result = await load_repo_skills(
mock_async_remote_workspace, 'owner/repo', '/workspace/project'
)
assert len(result) == 3
# Verify all skills are present (merged with precedence)
assert special_skills[0] in result
assert skills_dir_skills[0] in result
assert microagents_dir_skills[0] in result
@pytest.mark.asyncio
@patch('openhands.app_server.app_conversation.skill_loader._load_special_files')
@patch(
'openhands.app_server.app_conversation.skill_loader._find_and_load_skill_md_files'
)
async def test_load_repo_skills_no_selected_repository(
self,
mock_find_and_load,
mock_load_special,
mock_async_remote_workspace,
mock_skills_list,
):
"""Test loading repo skills without selected repository."""
mock_load_special.return_value = [mock_skills_list[0]]
mock_find_and_load.return_value = []
result = await load_repo_skills(
mock_async_remote_workspace, None, '/workspace/project'
)
assert len(result) == 1
# Verify repo root is working_dir when no repository selected
mock_load_special.assert_called_once_with(
mock_async_remote_workspace, '/workspace/project', '/workspace/project'
)
# Verify both directories were checked
assert mock_find_and_load.call_count == 2
@pytest.mark.asyncio
@patch('openhands.app_server.app_conversation.skill_loader._load_special_files')
async def test_load_repo_skills_exception(
self, mock_load_special, mock_async_remote_workspace
):
"""Test handling exception during repo skill loading."""
mock_load_special.side_effect = Exception('Workspace error')
result = await load_repo_skills(
mock_async_remote_workspace, 'owner/repo', '/workspace/project'
)
assert len(result) == 0
class TestMergeSkills:
"""Test merge_skills function."""
def test_merge_skills_no_duplicates(self):
"""Test merging skills with no duplicates."""
skill1 = Mock()
skill1.name = 'skill1'
skill2 = Mock()
skill2.name = 'skill2'
skill3 = Mock()
skill3.name = 'skill3'
result = merge_skills([[skill1], [skill2], [skill3]])
assert len(result) == 3
names = {s.name for s in result}
assert names == {'skill1', 'skill2', 'skill3'}
def test_merge_skills_with_duplicates(self):
"""Test merging skills with duplicates - later takes precedence."""
skill1_v1 = Mock()
skill1_v1.name = 'skill1'
skill1_v1.version = 'v1'
skill1_v2 = Mock()
skill1_v2.name = 'skill1'
skill1_v2.version = 'v2'
skill2 = Mock()
skill2.name = 'skill2'
result = merge_skills([[skill1_v1, skill2], [skill1_v2]])
assert len(result) == 2
names = {s.name for s in result}
assert names == {'skill1', 'skill2'}
# Verify later version takes precedence
skill1_result = next(s for s in result if s.name == 'skill1')
assert skill1_result.version == 'v2'
def test_merge_skills_empty_lists(self):
"""Test merging empty skill lists."""
result = merge_skills([[], [], []])
assert len(result) == 0
def test_merge_skills_single_list(self):
"""Test merging single skill list."""
skill1 = Mock()
skill1.name = 'skill1'
skill2 = Mock()
skill2.name = 'skill2'
result = merge_skills([[skill1, skill2]])
assert len(result) == 2
def test_merge_skills_precedence_order(self):
"""Test that skill precedence follows list order."""
# Create three versions of the same skill
skill_v1 = Mock()
skill_v1.name = 'test_skill'
skill_v1.priority = 'low'
skill_v2 = Mock()
skill_v2.name = 'test_skill'
skill_v2.priority = 'medium'
skill_v3 = Mock()
skill_v3.name = 'test_skill'
skill_v3.priority = 'high'
# List order: low -> medium -> high
# Should result in high priority (last one)
result = merge_skills([[skill_v1], [skill_v2], [skill_v3]])
assert len(result) == 1
assert result[0].priority == 'high'
def test_merge_skills_mixed_empty_and_filled(self):
"""Test merging with mix of empty and filled lists."""
skill1 = Mock()
skill1.name = 'skill1'
skill2 = Mock()
skill2.name = 'skill2'
result = merge_skills([[], [skill1], [], [skill2], []])
assert len(result) == 2
# ===== Integration Tests =====
class TestSkillLoaderIntegration:
"""Integration tests for the skill loader."""
@pytest.mark.asyncio
@patch('openhands.app_server.app_conversation.skill_loader.load_global_skills')
@patch('openhands.sdk.context.skills.load_user_skills')
@patch('openhands.app_server.app_conversation.skill_loader.load_repo_skills')
async def test_full_loading_workflow(
self,
mock_load_repo,
mock_load_user,
mock_load_global,
mock_async_remote_workspace,
):
"""Test the full workflow of loading all skill types."""
# Create distinct mock skills for each source
global_skill = Mock()
global_skill.name = 'global_skill'
user_skill = Mock()
user_skill.name = 'user_skill'
repo_skill = Mock()
repo_skill.name = 'repo_skill'
mock_load_global.return_value = [global_skill]
mock_load_user.return_value = [user_skill]
mock_load_repo.return_value = [repo_skill]
# Simulate loading all sources
global_skills = mock_load_global()
user_skills = mock_load_user()
repo_skills = await mock_load_repo(
mock_async_remote_workspace, 'owner/repo', '/workspace'
)
# Merge all skills
all_skills = merge_skills([global_skills, user_skills, repo_skills])
assert len(all_skills) == 3
names = {s.name for s in all_skills}
assert names == {'global_skill', 'user_skill', 'repo_skill'}
@pytest.mark.asyncio
@patch('openhands.app_server.app_conversation.skill_loader.load_global_skills')
@patch('openhands.sdk.context.skills.load_user_skills')
@patch('openhands.app_server.app_conversation.skill_loader.load_repo_skills')
async def test_loading_with_override_precedence(
self,
mock_load_repo,
mock_load_user,
mock_load_global,
mock_async_remote_workspace,
):
"""Test that repo skills override user skills, and user skills override global."""
# Create skills with same name but different sources
global_skill = Mock()
global_skill.name = 'common_skill'
global_skill.source = 'global'
user_skill = Mock()
user_skill.name = 'common_skill'
user_skill.source = 'user'
repo_skill = Mock()
repo_skill.name = 'common_skill'
repo_skill.source = 'repo'
mock_load_global.return_value = [global_skill]
mock_load_user.return_value = [user_skill]
mock_load_repo.return_value = [repo_skill]
# Load and merge in correct precedence order
global_skills = mock_load_global()
user_skills = mock_load_user()
repo_skills = await mock_load_repo(
mock_async_remote_workspace, 'owner/repo', '/workspace'
)
all_skills = merge_skills([global_skills, user_skills, repo_skills])
# Should have only one skill with repo source (highest precedence)
assert len(all_skills) == 1
assert all_skills[0].source == 'repo'