Skip to content

Commit

Permalink
Fix microagent loading with trailing slashes and nested directories (#…
Browse files Browse the repository at this point in the history
…6239)

Co-authored-by: openhands <[email protected]>
  • Loading branch information
xingyaoww and openhands-agent authored Jan 15, 2025
1 parent 8795ee6 commit 179a89a
Show file tree
Hide file tree
Showing 9 changed files with 382 additions and 96 deletions.
39 changes: 23 additions & 16 deletions openhands/microagent/microagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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
12 changes: 9 additions & 3 deletions openhands/resolver/patching/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
105 changes: 59 additions & 46 deletions openhands/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions tests/runtime/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/runtime/test_bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions tests/runtime/test_ipython.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
)

Expand All @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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()
)
Expand Down Expand Up @@ -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]'
)

Expand Down Expand Up @@ -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'})
Expand All @@ -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'})
Expand All @@ -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 = [
{
Expand Down
Loading

0 comments on commit 179a89a

Please sign in to comment.