feat: Add user directory support for microagents (#9333)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Engel Nyst 2025-06-27 04:31:59 +02:00 committed by GitHub
parent 94fe052561
commit 0fb1a712d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 258 additions and 0 deletions

View File

@ -2,6 +2,7 @@ import asyncio
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
import openhands
@ -33,6 +34,8 @@ GLOBAL_MICROAGENTS_DIR = os.path.join(
'microagents',
)
USER_MICROAGENTS_DIR = Path.home() / '.openhands' / 'microagents'
class Memory:
"""
@ -77,6 +80,9 @@ class Memory:
# from typically OpenHands/microagents (i.e., the PUBLIC microagents)
self._load_global_microagents()
# Load user microagents from ~/.openhands/microagents/
self._load_user_microagents()
def on_event(self, event: Event):
"""Handle an event from the event stream."""
asyncio.get_event_loop().run_until_complete(self._on_event(event))
@ -274,6 +280,41 @@ class Memory:
if isinstance(r_agent, RepoMicroagent):
self.repo_microagents[name] = r_agent
def _load_user_microagents(self) -> None:
"""
Loads microagents from the user's home directory (~/.openhands/microagents/)
Creates the directory if it doesn't exist.
"""
try:
# Create the user microagents directory if it doesn't exist
os.makedirs(USER_MICROAGENTS_DIR, exist_ok=True)
# Load microagents from user directory
repo_agents, knowledge_agents = load_microagents_from_dir(
USER_MICROAGENTS_DIR
)
# Add user microagents to the collections
# User microagents can override global ones with the same name
for name, agent in knowledge_agents.items():
if isinstance(agent, KnowledgeMicroagent):
self.knowledge_microagents[name] = agent
logger.debug(f'Loaded user knowledge microagent: {name}')
for name, agent in repo_agents.items():
if isinstance(agent, RepoMicroagent):
self.repo_microagents[name] = agent
logger.debug(f'Loaded user repo microagent: {name}')
if repo_agents or knowledge_agents:
logger.info(
f'Loaded {len(repo_agents) + len(knowledge_agents)} user microagents from {USER_MICROAGENTS_DIR}'
)
except Exception as e:
logger.warning(
f'Failed to load user microagents from {USER_MICROAGENTS_DIR}: {str(e)}'
)
def get_microagent_mcp_tools(self) -> list[MCPConfig]:
"""
Get MCP tools from all repo microagents (always active)

View File

@ -0,0 +1,217 @@
"""Tests for user directory microagent loading."""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from openhands.events.stream import EventStream
from openhands.memory.memory import Memory
from openhands.microagent import KnowledgeMicroagent, MicroagentType, RepoMicroagent
from openhands.storage import get_file_store
@pytest.fixture
def temp_user_microagents_dir():
"""Create a temporary directory to simulate ~/.openhands/microagents/."""
with tempfile.TemporaryDirectory() as temp_dir:
user_dir = Path(temp_dir)
# Create test knowledge agent
knowledge_agent = """---
name: user_knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- user-test
- personal
---
# User Knowledge Agent
Personal knowledge and guidelines.
"""
(user_dir / 'user_knowledge.md').write_text(knowledge_agent)
# Create test repo agent
repo_agent = """---
name: user_repo
version: 1.0.0
agent: CodeActAgent
---
# User Repository Agent
Personal repository-specific instructions.
"""
(user_dir / 'user_repo.md').write_text(repo_agent)
yield user_dir
def test_user_microagents_loading(temp_user_microagents_dir):
"""Test that user microagents are loaded from ~/.openhands/microagents/."""
with patch(
'openhands.memory.memory.USER_MICROAGENTS_DIR', str(temp_user_microagents_dir)
):
with tempfile.TemporaryDirectory() as temp_dir:
# Create event stream and memory
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('test', file_store)
memory = Memory(event_stream, 'test_sid')
# Check that user microagents were loaded
assert 'user_knowledge' in memory.knowledge_microagents
assert 'user_repo' in memory.repo_microagents
# Verify the loaded agents
user_knowledge = memory.knowledge_microagents['user_knowledge']
assert isinstance(user_knowledge, KnowledgeMicroagent)
assert user_knowledge.type == MicroagentType.KNOWLEDGE
assert 'user-test' in user_knowledge.triggers
assert 'personal' in user_knowledge.triggers
user_repo = memory.repo_microagents['user_repo']
assert isinstance(user_repo, RepoMicroagent)
assert user_repo.type == MicroagentType.REPO_KNOWLEDGE
def test_user_microagents_directory_creation():
"""Test that user microagents directory is created if it doesn't exist."""
with tempfile.TemporaryDirectory() as temp_dir:
non_existent_dir = Path(temp_dir) / 'non_existent' / 'microagents'
with patch(
'openhands.memory.memory.USER_MICROAGENTS_DIR', str(non_existent_dir)
):
with tempfile.TemporaryDirectory() as temp_store_dir:
# Create event stream and memory
file_store = get_file_store('local', temp_store_dir)
event_stream = EventStream('test', file_store)
Memory(event_stream, 'test_sid')
# Check that the directory was created
assert non_existent_dir.exists()
assert non_existent_dir.is_dir()
def test_user_microagents_override_global():
"""Test that user microagents can override global ones with the same name."""
with tempfile.TemporaryDirectory() as temp_dir:
user_dir = Path(temp_dir)
# Create a user microagent with the same name as a global one
# (assuming there's a global 'github' microagent)
github_agent = """---
name: github
version: 1.0.0
agent: CodeActAgent
triggers:
- github
- git
---
# Personal GitHub Agent
My personal GitHub workflow and preferences.
"""
(user_dir / 'github.md').write_text(github_agent)
with patch('openhands.memory.memory.USER_MICROAGENTS_DIR', str(user_dir)):
with tempfile.TemporaryDirectory() as temp_store_dir:
# Create event stream and memory
file_store = get_file_store('local', temp_store_dir)
event_stream = EventStream('test', file_store)
memory = Memory(event_stream, 'test_sid')
# Check that the user microagent is loaded
if 'github' in memory.knowledge_microagents:
github_microagent = memory.knowledge_microagents['github']
# The user version should contain our personal content
assert 'My personal GitHub workflow' in github_microagent.content
def test_user_microagents_loading_error_handling():
"""Test error handling when user microagents directory has issues."""
with tempfile.TemporaryDirectory() as temp_dir:
user_dir = Path(temp_dir)
# Create an invalid microagent file
invalid_agent = """---
name: invalid
type: invalid_type
---
# Invalid Agent
"""
(user_dir / 'invalid.md').write_text(invalid_agent)
with patch('openhands.memory.memory.USER_MICROAGENTS_DIR', str(user_dir)):
with tempfile.TemporaryDirectory() as temp_store_dir:
# Create event stream and memory - should not crash
file_store = get_file_store('local', temp_store_dir)
event_stream = EventStream('test', file_store)
memory = Memory(event_stream, 'test_sid')
# Memory should still be created despite the invalid microagent
assert memory is not None
# The invalid microagent should not be loaded
assert 'invalid' not in memory.knowledge_microagents
assert 'invalid' not in memory.repo_microagents
def test_user_microagents_empty_directory():
"""Test behavior when user microagents directory is empty."""
with tempfile.TemporaryDirectory() as temp_dir:
empty_dir = Path(temp_dir)
with patch('openhands.memory.memory.USER_MICROAGENTS_DIR', str(empty_dir)):
with tempfile.TemporaryDirectory() as temp_store_dir:
# Create event stream and memory
file_store = get_file_store('local', temp_store_dir)
event_stream = EventStream('test', file_store)
memory = Memory(event_stream, 'test_sid')
# Memory should be created successfully
assert memory is not None
# No user microagents should be loaded, but global ones might be
# (we can't assert the exact count since global microagents may exist)
def test_user_microagents_nested_directories(temp_user_microagents_dir):
"""Test loading user microagents from nested directories."""
# Create nested microagent
nested_dir = temp_user_microagents_dir / 'personal' / 'tools'
nested_dir.mkdir(parents=True)
nested_agent = """---
name: personal_tool
version: 1.0.0
agent: CodeActAgent
triggers:
- personal-tool
---
# Personal Tool Agent
My personal development tools and workflows.
"""
(nested_dir / 'tool.md').write_text(nested_agent)
with patch(
'openhands.memory.memory.USER_MICROAGENTS_DIR', str(temp_user_microagents_dir)
):
with tempfile.TemporaryDirectory() as temp_store_dir:
# Create event stream and memory
file_store = get_file_store('local', temp_store_dir)
event_stream = EventStream('test', file_store)
memory = Memory(event_stream, 'test_sid')
# Check that nested microagent was loaded
# The name should be derived from the relative path
assert 'personal/tools/tool' in memory.knowledge_microagents
nested_microagent = memory.knowledge_microagents['personal/tools/tool']
assert isinstance(nested_microagent, KnowledgeMicroagent)
assert 'personal-tool' in nested_microagent.triggers