Repository: All-Hands-AI/OpenHands
Pull request: feat: Add user directory support for microagents
- [x] This change is worth documenting at https://docs.all-hands.dev/
- [x] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below

**End-user friendly description of the problem this fixes or functionality this introduces.**

Supports reading microagents from the user directory (`~/.openhands/microagents/`) in addition to existing sources.

(openhands-agent, task via vscode because we can 😅)

This allows you to store personal microagents that persist across all OpenHands projects without keeping uncommitted files in your repository working directories, preventing accidental loss during git operations.

---
**Summarize what the PR does, explaining any non-trivial design decisions.**

This PR adds user directory support for microagents.

**Design decisions:**
- User microagents are loaded after global microagents, allowing users to override global ones
- Directory is created automatically to provide a smooth user experience
- Errors in user microagents don't crash the system, just log warnings
- Uses existing `load_microagents_from_dir()` function for consistency

**Benefits:**
- Persistent personal microagents across all OpenHands projects
- No more uncommitted microagent files in repository working directories
- Prevents accidental loss during git operations
- Maintains full backward compatibility

---
**Link of any specific issues this addresses:**

This addresses the problem mentioned in our development workflow where `local.md` and `local-gem.md` microagents were accidentally lost during `git add .` and `git reset --hard` operations because they were kept as uncommitted files in the repository working directory.

Fix https://github.com/All-Hands-AI/OpenHands/issues/9325

---

To run this PR locally, use the following command:
```
docker run -it --rm   -p 3000:3000   -v /var/run/docker.sock:/var/run/docker.sock   --add-host host.docker.internal:host-gateway   -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:03ec0d3-nikolaik   --name openhands-app-03ec0d3   docker.all-hands.dev/all-hands-ai/openhands:03ec0d3
```

Problem statement:
Add User Directory Support for Microagents
(openhands-agent, after it made a funny boo-boo and proved it uses `git reflog` better than me 😅)

## Problem
Currently, microagents are read from multiple locations but not from a user-specific directory. This leads to the problem of having uncommitted local microagents in the repository, which can be accidentally lost during git operations (as we experienced when `local.md` and `local-gem.md` were lost during a `git add .` and `git reset --hard`).

## Desired Behavior
The microagents system in OpenHands should read microagents from `~/.openhands/microagents/` in addition to its existing sources. This would allow users to:

1. Store personal/local microagents in their user directory (`~/.openhands/microagents/`)
2. Avoid keeping uncommitted microagent files in repository working directories
3. Have persistent personal microagents across all OpenHands projects

## Current Microagent Sources
As of now, microagents are read from multiple locations, but the user directory is not included. We need to implement reading them from the user directory.

## Implementation Requirements
- Add `~/.openhands/microagents/` as a microagent source
- Ensure proper loading order and precedence
- Maintain compatibility with existing microagent sources
- Handle cases where the user directory doesn't exist (create it or gracefully skip)

## Best Practices for Users
- **Personal/local microagents**: Add them to `~/.openhands/microagents/` instead of keeping them uncommitted in repository directories
- **Shared microagents**: If microagents are intended to be shared with other users, commit them to the repository's `.openhands/microagents/` directory
- **Never keep microagents uncommitted** in repository working directories to avoid accidental loss during git operations

## Files Affected
- Need to identify where microagent loading logic is implemented
- Likely in the microagents system/loader code
- May need to update configuration or initialization code

## Testing
- Verify microagents are loaded from user directory
- Test precedence/priority of different microagent sources
- Ensure backward compatibility with existing microagent locations

Hints from issue discussion:

Commit message: feat: Add user directory support for microagents

- Add ~/.openhands/microagents/ as a microagent source directory
- User microagents are loaded after global ones, allowing overrides
- Automatically create user microagents directory if it doesn't exist
- Add comprehensive unit tests for user microagent functionality
- Handle errors gracefully when loading user microagents

This allows users to store personal/local microagents in their user
directory instead of keeping uncommitted files in repository working
directories, preventing accidental loss during git operations.

Co-authored-by: openhands <openhands@all-hands.dev>

Patch to review:
diff --git a/openhands/memory/memory.py b/openhands/memory/memory.py
index 488dc795d61f..e022db3e8804 100644
--- a/openhands/memory/memory.py
+++ b/openhands/memory/memory.py
@@ -33,6 +33,8 @@
     'microagents',
 )
 
+USER_MICROAGENTS_DIR = os.path.expanduser('~/.openhands/microagents')
+
 
 class Memory:
     """
@@ -75,6 +77,9 @@ def __init__(
         # 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))
@@ -272,6 +277,41 @@ def _load_global_microagents(self) -> None:
             if isinstance(agent, RepoMicroagent):
                 self.repo_microagents[name] = 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)
diff --git a/tests/unit/test_user_microagents.py b/tests/unit/test_user_microagents.py
new file mode 100644
index 000000000000..b30f1cd03179
--- /dev/null
+++ b/tests/unit/test_user_microagents.py
@@ -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


Code review comments:
diff_hunk: @@ -33,6 +33,8 @@
     'microagents',
 )
 
+USER_MICROAGENTS_DIR = os.path.expanduser('~/.openhands/microagents')
path: openhands/memory/memory.py
text: @user1:
Will this only work on linux-based OSs? There might be a more OS-agnostic way of getting the home directory.

@user2:
I think `os.path` handles the ambiguity, but not sure

@author:
I think it should work, and we already use something like this [here](https://github.com/All-Hands-AI/OpenHands/blob/8f566a4247f5bdc7bb702df929be28233ea945c3/openhands/storage/local.py#L12) for example.

I changed it to a better way though!