mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
fix(backend): ensure microagents are loaded for V1 conversations (#11772)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
This commit is contained in:
parent
bede37fdb6
commit
36cf4e161a
@ -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
|
||||
|
||||
@ -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 ./
|
||||
|
||||
@ -40,6 +40,7 @@ export type V1AppConversationStartTaskStatus =
|
||||
| "PREPARING_REPOSITORY"
|
||||
| "RUNNING_SETUP_SCRIPT"
|
||||
| "SETTING_UP_GIT_HOOKS"
|
||||
| "SETTING_UP_SKILLS"
|
||||
| "STARTING_CONVERSATION"
|
||||
| "READY"
|
||||
| "ERROR";
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
@ -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,
|
||||
|
||||
331
openhands/app_server/app_conversation/skill_loader.py
Normal file
331
openhands/app_server/app_conversation/skill_loader.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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']:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
@ -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)
|
||||
756
tests/unit/app_server/test_skill_loader.py
Normal file
756
tests/unit/app_server/test_skill_loader.py
Normal 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'
|
||||
Loading…
x
Reference in New Issue
Block a user