diff --git a/openhands/microagent/microagent.py b/openhands/microagent/microagent.py index 1385436a422d..a2cd929307b7 100644 --- a/openhands/microagent/microagent.py +++ b/openhands/microagent/microagent.py @@ -8,6 +8,7 @@ from openhands.core.exceptions import ( MicroAgentValidationError, ) +from openhands.core.logger import openhands_logger as logger from openhands.microagent.types import MicroAgentMetadata, MicroAgentType @@ -132,8 +133,10 @@ def load_microagents_from_dir( ]: """Load all microagents from the given directory. + Note, legacy repo instructions will not be loaded here. + Args: - microagent_dir: Path to the microagents directory. + microagent_dir: Path to the microagents directory (e.g. .openhands/microagents) Returns: Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries @@ -145,20 +148,24 @@ def load_microagents_from_dir( knowledge_agents = {} task_agents = {} - # Load all agents - for file in microagent_dir.rglob('*.md'): - # skip README.md - if file.name == 'README.md': - continue - try: - agent = BaseMicroAgent.load(file) - if isinstance(agent, RepoMicroAgent): - repo_agents[agent.name] = agent - elif isinstance(agent, KnowledgeMicroAgent): - knowledge_agents[agent.name] = agent - elif isinstance(agent, TaskMicroAgent): - task_agents[agent.name] = agent - except Exception as e: - raise ValueError(f'Error loading agent from {file}: {e}') + # Load all agents from .openhands/microagents directory + logger.debug(f'Loading agents from {microagent_dir}') + if microagent_dir.exists(): + for file in microagent_dir.rglob('*.md'): + logger.debug(f'Checking file {file}...') + # skip README.md + if file.name == 'README.md': + continue + try: + agent = BaseMicroAgent.load(file) + if isinstance(agent, RepoMicroAgent): + repo_agents[agent.name] = agent + elif isinstance(agent, KnowledgeMicroAgent): + knowledge_agents[agent.name] = agent + elif isinstance(agent, TaskMicroAgent): + task_agents[agent.name] = agent + logger.debug(f'Loaded agent {agent.name} from {file}') + except Exception as e: + raise ValueError(f'Error loading agent from {file}: {e}') return repo_agents, knowledge_agents, task_agents diff --git a/openhands/resolver/patching/patch.py b/openhands/resolver/patching/patch.py index 82c67c1b756a..97cb5d488293 100644 --- a/openhands/resolver/patching/patch.py +++ b/openhands/resolver/patching/patch.py @@ -610,10 +610,14 @@ def parse_unified_diff(text): # - Start at line 1 in the old file and show 6 lines # - Start at line 1 in the new file and show 6 lines old = int(h.group(1)) # Starting line in old file - old_len = int(h.group(2)) if len(h.group(2)) > 0 else 1 # Number of lines in old file + old_len = ( + int(h.group(2)) if len(h.group(2)) > 0 else 1 + ) # Number of lines in old file new = int(h.group(3)) # Starting line in new file - new_len = int(h.group(4)) if len(h.group(4)) > 0 else 1 # Number of lines in new file + new_len = ( + int(h.group(4)) if len(h.group(4)) > 0 else 1 + ) # Number of lines in new file h = None break @@ -622,7 +626,9 @@ def parse_unified_diff(text): for n in hunk: # Each line in a unified diff starts with a space (context), + (addition), or - (deletion) # The first character is the kind, the rest is the line content - kind = n[0] if len(n) > 0 else ' ' # Empty lines in the hunk are treated as context lines + kind = ( + n[0] if len(n) > 0 else ' ' + ) # Empty lines in the hunk are treated as context lines line = n[1:] if len(n) > 1 else '' # Process the line based on its kind diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 3bd32a9a099b..114289f390b2 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -4,10 +4,13 @@ import json import os import random +import shutil import string +import tempfile from abc import abstractmethod from pathlib import Path from typing import Callable +from zipfile import ZipFile from requests.exceptions import ConnectionError @@ -37,9 +40,7 @@ from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS from openhands.microagent import ( BaseMicroAgent, - KnowledgeMicroAgent, - RepoMicroAgent, - TaskMicroAgent, + load_microagents_from_dir, ) from openhands.runtime.plugins import ( JupyterRequirement, @@ -228,21 +229,37 @@ def clone_repo(self, github_token: str, selected_repository: str) -> str: def get_microagents_from_selected_repo( self, selected_repository: str | None ) -> list[BaseMicroAgent]: + """Load microagents from the selected repository. + If selected_repository is None, load microagents from the current workspace. + + This is the main entry point for loading microagents. + """ + loaded_microagents: list[BaseMicroAgent] = [] - dir_name = Path('.openhands') / 'microagents' + workspace_root = Path(self.config.workspace_mount_path_in_sandbox) + microagents_dir = workspace_root / '.openhands' / 'microagents' + repo_root = None if selected_repository: - dir_name = Path('/workspace') / selected_repository.split('/')[1] / dir_name + repo_root = workspace_root / selected_repository.split('/')[1] + microagents_dir = repo_root / '.openhands' / 'microagents' + self.log( + 'info', + f'Selected repo: {selected_repository}, loading microagents from {microagents_dir} (inside runtime)', + ) # Legacy Repo Instructions # Check for legacy .openhands_instructions file - obs = self.read(FileReadAction(path='.openhands_instructions')) - if isinstance(obs, ErrorObservation): + obs = self.read( + FileReadAction(path=str(workspace_root / '.openhands_instructions')) + ) + if isinstance(obs, ErrorObservation) and repo_root is not None: + # If the instructions file is not found in the workspace root, try to load it from the repo root self.log( 'debug', - f'openhands_instructions not present, trying to load from {dir_name}', + f'.openhands_instructions not present, trying to load from repository {microagents_dir=}', ) obs = self.read( - FileReadAction(path=str(dir_name / '.openhands_instructions')) + FileReadAction(path=str(repo_root / '.openhands_instructions')) ) if isinstance(obs, FileReadObservation): @@ -253,44 +270,40 @@ def get_microagents_from_selected_repo( ) ) - # Check for local repository microagents - files = self.list_files(str(dir_name)) - self.log('info', f'Found {len(files)} local microagents.') - if 'repo.md' in files: - obs = self.read(FileReadAction(path=str(dir_name / 'repo.md'))) - if isinstance(obs, FileReadObservation): - self.log('info', 'repo.md microagent loaded.') - loaded_microagents.append( - RepoMicroAgent.load( - path=str(dir_name / 'repo.md'), file_content=obs.content - ) - ) + # Load microagents from directory + files = self.list_files(str(microagents_dir)) + if files: + self.log('info', f'Found {len(files)} files in microagents directory.') + zip_path = self.copy_from(str(microagents_dir)) + microagent_folder = tempfile.mkdtemp() + + # Properly handle the zip file + with ZipFile(zip_path, 'r') as zip_file: + zip_file.extractall(microagent_folder) + + # Add debug print of directory structure + self.log('debug', 'Microagent folder structure:') + for root, _, files in os.walk(microagent_folder): + relative_path = os.path.relpath(root, microagent_folder) + self.log('debug', f'Directory: {relative_path}/') + for file in files: + self.log('debug', f' File: {os.path.join(relative_path, file)}') + + # Clean up the temporary zip file + zip_path.unlink() + # Load all microagents using the existing function + repo_agents, knowledge_agents, task_agents = load_microagents_from_dir( + microagent_folder + ) + self.log( + 'info', + f'Loaded {len(repo_agents)} repo agents, {len(knowledge_agents)} knowledge agents, and {len(task_agents)} task agents', + ) + loaded_microagents.extend(repo_agents.values()) + loaded_microagents.extend(knowledge_agents.values()) + loaded_microagents.extend(task_agents.values()) + shutil.rmtree(microagent_folder) - if 'knowledge' in files: - knowledge_dir = dir_name / 'knowledge' - _knowledge_microagents_files = self.list_files(str(knowledge_dir)) - for fname in _knowledge_microagents_files: - obs = self.read(FileReadAction(path=str(knowledge_dir / fname))) - if isinstance(obs, FileReadObservation): - self.log('info', f'knowledge/{fname} microagent loaded.') - loaded_microagents.append( - KnowledgeMicroAgent.load( - path=str(knowledge_dir / fname), file_content=obs.content - ) - ) - - if 'tasks' in files: - tasks_dir = dir_name / 'tasks' - _tasks_microagents_files = self.list_files(str(tasks_dir)) - for fname in _tasks_microagents_files: - obs = self.read(FileReadAction(path=str(tasks_dir / fname))) - if isinstance(obs, FileReadObservation): - self.log('info', f'tasks/{fname} microagent loaded.') - loaded_microagents.append( - TaskMicroAgent.load( - path=str(tasks_dir / fname), file_content=obs.content - ) - ) return loaded_microagents def run_action(self, action: Action) -> Observation: diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index a8ee81f94596..8e5119e63189 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -26,7 +26,7 @@ project_dir = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) -sandbox_test_folder = '/openhands/workspace' +sandbox_test_folder = '/workspace' def _get_runtime_sid(runtime: Runtime) -> str: @@ -233,9 +233,10 @@ def _load_runtime( if use_workspace: test_mount_path = os.path.join(config.workspace_base, 'rt') elif temp_dir is not None: - test_mount_path = os.path.join(temp_dir, sid) + test_mount_path = temp_dir else: test_mount_path = None + config.workspace_base = test_mount_path config.workspace_mount_path = test_mount_path # Mounting folder specific for this test inside the sandbox diff --git a/tests/runtime/test_bash.py b/tests/runtime/test_bash.py index 75f9085815fa..3a25fd01ddee 100644 --- a/tests/runtime/test_bash.py +++ b/tests/runtime/test_bash.py @@ -210,7 +210,7 @@ def test_multiline_command_loop(temp_dir, runtime_cls): def test_cmd_run(temp_dir, runtime_cls, run_as_openhands): runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands) try: - obs = _run_cmd_action(runtime, 'ls -l /openhands/workspace') + obs = _run_cmd_action(runtime, 'ls -l /workspace') assert obs.exit_code == 0 obs = _run_cmd_action(runtime, 'ls -l') @@ -377,7 +377,7 @@ def test_copy_to_non_existent_directory(temp_dir, runtime_cls): def test_overwrite_existing_file(temp_dir, runtime_cls): runtime = _load_runtime(temp_dir, runtime_cls) try: - sandbox_dir = '/openhands/workspace' + sandbox_dir = '/workspace' obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}') assert obs.exit_code == 0 diff --git a/tests/runtime/test_ipython.py b/tests/runtime/test_ipython.py index 8b5c89053b52..11b5db67fe9d 100644 --- a/tests/runtime/test_ipython.py +++ b/tests/runtime/test_ipython.py @@ -52,7 +52,7 @@ def test_simple_cmd_ipython_and_fileop(temp_dir, runtime_cls, run_as_openhands): logger.info(obs, extra={'msg_type': 'OBSERVATION'}) assert obs.content.strip() == ( 'Hello, `World`!\n' - '[Jupyter current working directory: /openhands/workspace]\n' + '[Jupyter current working directory: /workspace]\n' '[Jupyter Python interpreter: /openhands/poetry/openhands-ai-5O4_aCHf-py3.12/bin/python]' ) @@ -73,7 +73,7 @@ def test_simple_cmd_ipython_and_fileop(temp_dir, runtime_cls, run_as_openhands): assert obs.content == '' # event stream runtime will always use absolute path - assert obs.path == '/openhands/workspace/hello.sh' + assert obs.path == '/workspace/hello.sh' # Test read file (file should exist) action_read = FileReadAction(path='hello.sh') @@ -85,7 +85,7 @@ def test_simple_cmd_ipython_and_fileop(temp_dir, runtime_cls, run_as_openhands): logger.info(obs, extra={'msg_type': 'OBSERVATION'}) assert obs.content == 'echo "Hello, World!"\n' - assert obs.path == '/openhands/workspace/hello.sh' + assert obs.path == '/workspace/hello.sh' # clean up action = CmdRunAction(command='rm -rf hello.sh') @@ -188,7 +188,7 @@ def test_ipython_simple(temp_dir, runtime_cls): obs.content.strip() == ( '1\n' - '[Jupyter current working directory: /openhands/workspace]\n' + '[Jupyter current working directory: /workspace]\n' '[Jupyter Python interpreter: /openhands/poetry/openhands-ai-5O4_aCHf-py3.12/bin/python]' ).strip() ) @@ -224,7 +224,7 @@ def test_ipython_package_install(temp_dir, runtime_cls, run_as_openhands): # import should not error out assert obs.content.strip() == ( '[Code executed successfully with no output]\n' - '[Jupyter current working directory: /openhands/workspace]\n' + '[Jupyter current working directory: /workspace]\n' '[Jupyter Python interpreter: /openhands/poetry/openhands-ai-5O4_aCHf-py3.12/bin/python]' ) @@ -273,16 +273,16 @@ def test_ipython_file_editor_permissions_as_openhands(temp_dir, runtime_cls): # Try to use file editor in openhands sandbox directory - should work test_code = """ # Create file -print(file_editor(command='create', path='/openhands/workspace/test.txt', file_text='Line 1\\nLine 2\\nLine 3')) +print(file_editor(command='create', path='/workspace/test.txt', file_text='Line 1\\nLine 2\\nLine 3')) # View file -print(file_editor(command='view', path='/openhands/workspace/test.txt')) +print(file_editor(command='view', path='/workspace/test.txt')) # Edit file -print(file_editor(command='str_replace', path='/openhands/workspace/test.txt', old_str='Line 2', new_str='New Line 2')) +print(file_editor(command='str_replace', path='/workspace/test.txt', old_str='Line 2', new_str='New Line 2')) # Undo edit -print(file_editor(command='undo_edit', path='/openhands/workspace/test.txt')) +print(file_editor(command='undo_edit', path='/workspace/test.txt')) """ action = IPythonRunCellAction(code=test_code) logger.info(action, extra={'msg_type': 'ACTION'}) @@ -297,7 +297,7 @@ def test_ipython_file_editor_permissions_as_openhands(temp_dir, runtime_cls): assert 'undone successfully' in obs.content # Clean up - action = CmdRunAction(command='rm -f /openhands/workspace/test.txt') + action = CmdRunAction(command='rm -f /workspace/test.txt') logger.info(action, extra={'msg_type': 'ACTION'}) obs = runtime.run_action(action) logger.info(obs, extra={'msg_type': 'OBSERVATION'}) @@ -314,7 +314,7 @@ def test_ipython_file_editor_permissions_as_openhands(temp_dir, runtime_cls): def test_file_read_and_edit_via_oh_aci(runtime_cls, run_as_openhands): runtime = _load_runtime(None, runtime_cls, run_as_openhands) - sandbox_dir = '/openhands/workspace' + sandbox_dir = '/workspace' actions = [ { diff --git a/tests/runtime/test_microagent.py b/tests/runtime/test_microagent.py new file mode 100644 index 000000000000..6f0305f6fb96 --- /dev/null +++ b/tests/runtime/test_microagent.py @@ -0,0 +1,197 @@ +"""Tests for microagent loading in runtime.""" + +from pathlib import Path + +from conftest import ( + _close_test_runtime, + _load_runtime, +) + +from openhands.microagent import KnowledgeMicroAgent, RepoMicroAgent, TaskMicroAgent + + +def _create_test_microagents(test_dir: str): + """Create test microagent files in the given directory.""" + microagents_dir = Path(test_dir) / '.openhands' / 'microagents' + microagents_dir.mkdir(parents=True, exist_ok=True) + + # Create test knowledge agent + knowledge_dir = microagents_dir / 'knowledge' + knowledge_dir.mkdir(exist_ok=True) + knowledge_agent = """--- +name: test_knowledge_agent +type: knowledge +version: 1.0.0 +agent: CodeActAgent +triggers: + - test + - pytest +--- + +# Test Guidelines + +Testing best practices and guidelines. +""" + (knowledge_dir / 'knowledge.md').write_text(knowledge_agent) + + # Create test repo agent + repo_agent = """--- +name: test_repo_agent +type: repo +version: 1.0.0 +agent: CodeActAgent +--- + +# Test Repository Agent + +Repository-specific test instructions. +""" + (microagents_dir / 'repo.md').write_text(repo_agent) + + # Create test task agent in a nested directory + task_dir = microagents_dir / 'tasks' / 'nested' + task_dir.mkdir(parents=True, exist_ok=True) + task_agent = """--- +name: test_task +type: task +version: 1.0.0 +agent: CodeActAgent +--- + +# Test Task + +Test task content +""" + (task_dir / 'task.md').write_text(task_agent) + + # Create legacy repo instructions + legacy_instructions = """# Legacy Instructions + +These are legacy repository instructions. +""" + (Path(test_dir) / '.openhands_instructions').write_text(legacy_instructions) + + +def test_load_microagents_with_trailing_slashes( + temp_dir, runtime_cls, run_as_openhands +): + """Test loading microagents when directory paths have trailing slashes.""" + # Create test files + _create_test_microagents(temp_dir) + runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Load microagents + loaded_agents = runtime.get_microagents_from_selected_repo(None) + + # Verify all agents are loaded + knowledge_agents = [ + a for a in loaded_agents if isinstance(a, KnowledgeMicroAgent) + ] + repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroAgent)] + task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroAgent)] + + # Check knowledge agents + assert len(knowledge_agents) == 1 + agent = knowledge_agents[0] + assert agent.name == 'test_knowledge_agent' + assert 'test' in agent.triggers + assert 'pytest' in agent.triggers + + # Check repo agents (including legacy) + assert len(repo_agents) == 2 # repo.md + .openhands_instructions + repo_names = {a.name for a in repo_agents} + assert 'test_repo_agent' in repo_names + assert 'repo_legacy' in repo_names + + # Check task agents + assert len(task_agents) == 1 + agent = task_agents[0] + assert agent.name == 'test_task' + + finally: + _close_test_runtime(runtime) + + +def test_load_microagents_with_selected_repo(temp_dir, runtime_cls, run_as_openhands): + """Test loading microagents from a selected repository.""" + # Create test files in a repository-like structure + repo_dir = Path(temp_dir) / 'OpenHands' + repo_dir.mkdir(parents=True) + _create_test_microagents(str(repo_dir)) + + runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Load microagents with selected repository + loaded_agents = runtime.get_microagents_from_selected_repo( + 'All-Hands-AI/OpenHands' + ) + + # Verify all agents are loaded + knowledge_agents = [ + a for a in loaded_agents if isinstance(a, KnowledgeMicroAgent) + ] + repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroAgent)] + task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroAgent)] + + # Check knowledge agents + assert len(knowledge_agents) == 1 + agent = knowledge_agents[0] + assert agent.name == 'test_knowledge_agent' + assert 'test' in agent.triggers + assert 'pytest' in agent.triggers + + # Check repo agents (including legacy) + assert len(repo_agents) == 2 # repo.md + .openhands_instructions + repo_names = {a.name for a in repo_agents} + assert 'test_repo_agent' in repo_names + assert 'repo_legacy' in repo_names + + # Check task agents + assert len(task_agents) == 1 + agent = task_agents[0] + assert agent.name == 'test_task' + + finally: + _close_test_runtime(runtime) + + +def test_load_microagents_with_missing_files(temp_dir, runtime_cls, run_as_openhands): + """Test loading microagents when some files are missing.""" + # Create only repo.md, no other files + microagents_dir = Path(temp_dir) / '.openhands' / 'microagents' + microagents_dir.mkdir(parents=True, exist_ok=True) + + repo_agent = """--- +name: test_repo_agent +type: repo +version: 1.0.0 +agent: CodeActAgent +--- + +# Test Repository Agent + +Repository-specific test instructions. +""" + (microagents_dir / 'repo.md').write_text(repo_agent) + + runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Load microagents + loaded_agents = runtime.get_microagents_from_selected_repo(None) + + # Verify only repo agent is loaded + knowledge_agents = [ + a for a in loaded_agents if isinstance(a, KnowledgeMicroAgent) + ] + repo_agents = [a for a in loaded_agents if isinstance(a, RepoMicroAgent)] + task_agents = [a for a in loaded_agents if isinstance(a, TaskMicroAgent)] + + assert len(knowledge_agents) == 0 + assert len(repo_agents) == 1 + assert len(task_agents) == 0 + + agent = repo_agents[0] + assert agent.name == 'test_repo_agent' + + finally: + _close_test_runtime(runtime) diff --git a/tests/unit/resolver/test_patch_apply.py b/tests/unit/resolver/test_patch_apply.py index 3528483cb148..eb6cef2c43aa 100644 --- a/tests/unit/resolver/test_patch_apply.py +++ b/tests/unit/resolver/test_patch_apply.py @@ -1,12 +1,10 @@ -import pytest from openhands.resolver.patching.apply import apply_diff -from openhands.resolver.patching.exceptions import HunkApplyException -from openhands.resolver.patching.patch import parse_diff, diffobj +from openhands.resolver.patching.patch import diffobj, parse_diff def test_patch_apply_with_empty_lines(): # The original file has no indentation and uses \n line endings - original_content = "# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup" + original_content = '# PR Viewer\n\nThis React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.\n\n## Setup' # The patch has spaces at the start of each line and uses \n line endings patch = """diff --git a/README.md b/README.md @@ -19,18 +17,20 @@ def test_patch_apply_with_empty_lines(): -This React application allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization. +This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.""" - print("Original content lines:") + print('Original content lines:') for i, line in enumerate(original_content.splitlines(), 1): - print(f"{i}: {repr(line)}") + print(f'{i}: {repr(line)}') - print("\nPatch lines:") + print('\nPatch lines:') for i, line in enumerate(patch.splitlines(), 1): - print(f"{i}: {repr(line)}") + print(f'{i}: {repr(line)}') changes = parse_diff(patch) - print("\nParsed changes:") + print('\nParsed changes:') for change in changes: - print(f"Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})") + print( + f'Change(old={change.old}, new={change.new}, line={repr(change.line)}, hunk={change.hunk})' + ) diff = diffobj(header=None, changes=changes, text=patch) # Apply the patch @@ -38,10 +38,10 @@ def test_patch_apply_with_empty_lines(): # The patch should be applied successfully expected_result = [ - "# PR Viewer", - "", - "This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.", - "", - "## Setup" + '# PR Viewer', + '', + 'This React application was created by Graham Neubig and OpenHands. It allows you to view open pull requests from GitHub repositories in a GitHub organization. By default, it uses the All-Hands-AI organization.', + '', + '## Setup', ] - assert result == expected_result \ No newline at end of file + assert result == expected_result diff --git a/tests/unit/test_microagent_utils.py b/tests/unit/test_microagent_utils.py index dca121909f84..64d423b469e8 100644 --- a/tests/unit/test_microagent_utils.py +++ b/tests/unit/test_microagent_utils.py @@ -143,3 +143,65 @@ def test_invalid_agent_type(temp_microagents_dir): with pytest.raises(MicroAgentValidationError): BaseMicroAgent.load(temp_microagents_dir / 'invalid.md') + + +def test_load_microagents_with_nested_dirs(temp_microagents_dir): + """Test loading microagents from nested directories.""" + # Create nested knowledge agent + nested_dir = temp_microagents_dir / 'nested' / 'dir' + nested_dir.mkdir(parents=True) + nested_agent = """--- +name: nested_knowledge_agent +type: knowledge +version: 1.0.0 +agent: CodeActAgent +triggers: + - nested +--- + +# Nested Test Guidelines + +Testing nested directory loading. +""" + (nested_dir / 'nested.md').write_text(nested_agent) + + repo_agents, knowledge_agents, task_agents = load_microagents_from_dir( + temp_microagents_dir + ) + + # Check that we can find the nested agent + assert len(knowledge_agents) == 2 # Original + nested + agent = knowledge_agents['nested_knowledge_agent'] + assert isinstance(agent, KnowledgeMicroAgent) + assert 'nested' in agent.triggers + + +def test_load_microagents_with_trailing_slashes(temp_microagents_dir): + """Test loading microagents when directory paths have trailing slashes.""" + # Create a directory with trailing slash + knowledge_dir = temp_microagents_dir / 'knowledge/' + knowledge_dir.mkdir(exist_ok=True) + knowledge_agent = """--- +name: trailing_knowledge_agent +type: knowledge +version: 1.0.0 +agent: CodeActAgent +triggers: + - trailing +--- + +# Trailing Slash Test + +Testing loading with trailing slashes. +""" + (knowledge_dir / 'trailing.md').write_text(knowledge_agent) + + repo_agents, knowledge_agents, task_agents = load_microagents_from_dir( + str(temp_microagents_dir) + '/' # Add trailing slash to test + ) + + # Check that we can find the agent despite trailing slashes + assert len(knowledge_agents) == 2 # Original + trailing + agent = knowledge_agents['trailing_knowledge_agent'] + assert isinstance(agent, KnowledgeMicroAgent) + assert 'trailing' in agent.triggers