refactor: restructure microagents system (#5886)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
This commit is contained in:
Xingyao Wang 2025-01-02 17:13:18 -05:00 committed by GitHub
parent 8983d719bd
commit c1b514e9d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 771 additions and 142 deletions

View File

@ -1,6 +1,7 @@
---
name: repo
agent: CodeAct
type: repo
agent: CodeActAgent
---
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
(in the `openhands` directory) and React frontend (in the `frontend` directory).

View File

@ -71,6 +71,7 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
RUN playwright install --with-deps chromium
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:app --chmod=770 ./openhands/agenthub ./openhands/agenthub

163
microagents/README.md Normal file
View File

@ -0,0 +1,163 @@
# OpenHands MicroAgents
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.
## Sources of Microagents
OpenHands loads microagents from two sources:
### 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
Directory structure:
```
OpenHands/microagents/
├── knowledge/ # Keyword-triggered expertise
│ ├── git.md # Git operations
│ ├── testing.md # Testing practices
│ └── docker.md # Docker guidelines
└── tasks/ # Interactive workflows
├── 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:
- Private to that repository
- Automatically loaded when working with that repository
- Perfect for repository-specific guidelines and team practices
Example repository structure:
```
your-repository/
└── .openhands/
└── microagents/
└── repo.md # Repository-specific instructions
└── knowledges/ # Private micro-agents that are only available inside this repo
└── tasks/ # 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
2. Loads relevant knowledge agents based on keywords in conversations
3. Enable task agent if user select one of them
## Types of MicroAgents
All microagents use markdown files with YAML frontmatter.
### 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/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/github.md).
### 2. Repository Agents
Repository agents provide repository-specific knowledge and guidelines. They are:
- Loaded from `.openhands/microagents/repo.md`
- 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
- **Locally maintained**: Updated with the project
You can see an example of a repo agent in [the agent for the OpenHands repo itself](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md).
### 3. Task Agents
Task agents provide interactive workflows that guide users through common development tasks. They:
- Accept user inputs
- Follow predefined steps
- Adapt to context
- Provide consistent results
Key capabilities:
- **Interactive**: Guide users through complex processes
- **Validating**: Check inputs and conditions
- **Flexible**: Adapt to different scenarios
- **Reproducible**: Ensure consistent outcomes
Example workflow:
You can see an example of a task-based agent in [OpenHands's pull request updating microagent](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks/update_pr_description.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. **Task Agents** - When you have:
- Repeatable workflows
- Multi-step processes
- Common development tasks
- Standard procedures
3. **Repository Agents** - When you need:
- Project-specific guidelines
- Team conventions and practices
- Custom workflow documentation
- Repository-specific setup instructions
### 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 Task Agents**:
- Break workflows into clear steps
- Validate user inputs
- Provide helpful defaults
- Include usage examples
- Make steps adaptable
3. **For Repository Agents**:
- Document clear setup instructions
- Include repository structure details
- Specify testing and build procedures
- List environment requirements
- Maintain up-to-date team practices
### Submission Process
1. Create your agent file in the appropriate directory:
- `knowledge/` for expertise (public, shareable)
- `tasks/` for workflows (public, shareable)
- Note: Repository agents should remain in their respective repositories' `.openhands/microagents/` 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.

View File

@ -1,5 +1,7 @@
---
name: flarglebargle
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- flarglebargle

View File

@ -1,5 +1,7 @@
---
name: github
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- github
@ -26,4 +28,3 @@ git checkout -b create-widget && git add . && git commit -m "Create widget" && g
curl -X POST "https://api.github.com/repos/$ORG_NAME/$REPO_NAME/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
```

View File

@ -1,5 +1,7 @@
---
name: npm
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- npm

View File

@ -0,0 +1,20 @@
---
name: address_pr_comments
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: PR_URL
description: "URL of the pull request"
required: true
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
required: true
---
First, check the branch {{ BRANCH_NAME }} and read the diff against the main branch to understand the purpose.
This branch corresponds to this PR {{ PR_URL }}
Next, you should use the GitHub API to read the reviews and comments on this PR and address them.

View File

@ -0,0 +1,28 @@
---
name: get_test_to_pass
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
required: true
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
required: true
- name: FUNCTION_TO_FIX
description: "The name of function to fix"
required: false
- name: FILE_FOR_FUNCTION
description: "The path of the file that contains the function"
required: false
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
{%- endif %}
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.

View File

@ -0,0 +1,22 @@
---
name: update_pr_description
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: PR_URL
description: "URL of the pull request"
type: string
required: true
validation:
pattern: "^https://github.com/.+/.+/pull/[0-9]+$"
- name: BRANCH_NAME
description: "Branch name corresponds to the pull request"
type: string
required: true
---
Please check the branch "{{ BRANCH_NAME }}" and look at the diff against the main branch. This branch belongs to this PR "{{ PR_URL }}".
Once you understand the purpose of the diff, please use Github API to read the existing PR description, and update it to be more reflective of the changes we've made when necessary.

View File

@ -0,0 +1,22 @@
---
name: update_test_for_new_implementation
type: task
version: 1.0.0
author: openhands
agent: CodeActAgent
inputs:
- name: BRANCH_NAME
description: "Branch for the agent to work on"
required: true
- name: TEST_COMMAND_TO_RUN
description: "The test command you want the agent to work on. For example, `pytest tests/unit/test_bash_parsing.py`"
required: true
---
Can you check out branch "{{ BRANCH_NAME }}", and run {{ TEST_COMMAND_TO_RUN }}.
{%- if FUNCTION_TO_FIX and FILE_FOR_FUNCTION %}
Help me fix these tests to pass by fixing the {{ FUNCTION_TO_FIX }} function in file {{ FILE_FOR_FUNCTION }}.
{%- endif %}
PLEASE DO NOT modify the tests by yourselves -- Let me know if you think some of the tests are incorrect.

View File

@ -4,6 +4,7 @@ from collections import deque
from litellm import ModelResponse
import openhands
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
@ -104,7 +105,10 @@ class CodeActAgent(Agent):
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2, ensure_ascii=False).replace("\\n", "\n")}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro')
microagent_dir=os.path.join(
os.path.dirname(os.path.dirname(openhands.__file__)),
'microagents',
)
if self.config.use_microagents
else None,
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),

View File

@ -11,7 +11,9 @@ from openhands.core.exceptions import (
)
from openhands.llm.llm import LLM
from openhands.runtime.plugins import PluginRequirement
from openhands.utils.prompt import PromptManager
if TYPE_CHECKING:
from openhands.utils.prompt import PromptManager
class Agent(ABC):
@ -34,7 +36,7 @@ class Agent(ABC):
self.llm = llm
self.config = config
self._complete = False
self.prompt_manager: PromptManager | None = None
self.prompt_manager: 'PromptManager' | None = None
@property
def complete(self) -> bool:

View File

@ -91,11 +91,6 @@ class UserCancelledError(Exception):
super().__init__(message)
class MicroAgentValidationError(Exception):
def __init__(self, message='Micro agent validation failed'):
super().__init__(message)
class OperationCancelled(Exception):
"""Exception raised when an operation is cancelled (e.g. by a keyboard interrupt)."""
@ -204,3 +199,21 @@ class BrowserUnavailableException(Exception):
message='Browser environment is not available, please check if has been initialized',
):
super().__init__(message)
# ============================================
# Microagent Exceptions
# ============================================
class MicroAgentError(Exception):
"""Base exception for all microagent errors."""
pass
class MicroAgentValidationError(MicroAgentError):
"""Raised when there's a validation error in microagent metadata."""
def __init__(self, message='Micro agent validation failed'):
super().__init__(message)

View File

@ -0,0 +1,19 @@
from .microagent import (
BaseMicroAgent,
KnowledgeMicroAgent,
RepoMicroAgent,
TaskMicroAgent,
load_microagents_from_dir,
)
from .types import MicroAgentMetadata, MicroAgentType, TaskInput
__all__ = [
'BaseMicroAgent',
'KnowledgeMicroAgent',
'RepoMicroAgent',
'TaskMicroAgent',
'MicroAgentMetadata',
'MicroAgentType',
'TaskInput',
'load_microagents_from_dir',
]

View File

@ -0,0 +1,164 @@
import io
from pathlib import Path
from typing import Union
import frontmatter
from pydantic import BaseModel
from openhands.core.exceptions import (
MicroAgentValidationError,
)
from openhands.microagent.types import MicroAgentMetadata, MicroAgentType
class BaseMicroAgent(BaseModel):
"""Base class for all microagents."""
name: str
content: str
metadata: MicroAgentMetadata
source: str # path to the file
type: MicroAgentType
@classmethod
def load(
cls, path: Union[str, Path], file_content: str | None = None
) -> 'BaseMicroAgent':
"""Load a microagent from a markdown file with frontmatter."""
path = Path(path) if isinstance(path, str) else path
# Only load directly from path if file_content is not provided
if file_content is None:
with open(path) as f:
file_content = f.read()
# Legacy repo instructions are stored in .openhands_instructions
if path.name == '.openhands_instructions':
return RepoMicroAgent(
name='repo_legacy',
content=file_content,
metadata=MicroAgentMetadata(name='repo_legacy'),
source=str(path),
type=MicroAgentType.REPO_KNOWLEDGE,
)
file_io = io.StringIO(file_content)
loaded = frontmatter.load(file_io)
content = loaded.content
try:
metadata = MicroAgentMetadata(**loaded.metadata)
except Exception as e:
raise MicroAgentValidationError(f'Error loading metadata: {e}') from e
# Create appropriate subclass based on type
subclass_map = {
MicroAgentType.KNOWLEDGE: KnowledgeMicroAgent,
MicroAgentType.REPO_KNOWLEDGE: RepoMicroAgent,
MicroAgentType.TASK: TaskMicroAgent,
}
if metadata.type not in subclass_map:
raise ValueError(f'Unknown microagent type: {metadata.type}')
agent_class = subclass_map[metadata.type]
return agent_class(
name=metadata.name,
content=content,
metadata=metadata,
source=str(path),
type=metadata.type,
)
class KnowledgeMicroAgent(BaseMicroAgent):
"""Knowledge micro-agents provide specialized expertise that's triggered by keywords in conversations. They help with:
- Language best practices
- Framework guidelines
- Common patterns
- Tool usage
"""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroAgentType.KNOWLEDGE:
raise ValueError('KnowledgeMicroAgent must have type KNOWLEDGE')
def match_trigger(self, message: str) -> str | None:
"""Match a trigger in the message.
It returns the first trigger that matches the message.
"""
message = message.lower()
for trigger in self.triggers:
if trigger.lower() in message:
return trigger
return None
@property
def triggers(self) -> list[str]:
return self.metadata.triggers
class RepoMicroAgent(BaseMicroAgent):
"""MicroAgent specialized for repository-specific knowledge and guidelines.
RepoMicroAgents are loaded from `.openhands/microagents/repo.md` files within repositories
and contain private, repository-specific instructions that are automatically loaded when
working with that repository. They are ideal for:
- Repository-specific guidelines
- Team practices and conventions
- Project-specific workflows
- Custom documentation references
"""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroAgentType.REPO_KNOWLEDGE:
raise ValueError('RepoMicroAgent must have type REPO_KNOWLEDGE')
class TaskMicroAgent(BaseMicroAgent):
"""MicroAgent specialized for task-based operations."""
def __init__(self, **data):
super().__init__(**data)
if self.type != MicroAgentType.TASK:
raise ValueError('TaskMicroAgent must have type TASK')
def load_microagents_from_dir(
microagent_dir: Union[str, Path],
) -> tuple[
dict[str, RepoMicroAgent], dict[str, KnowledgeMicroAgent], dict[str, TaskMicroAgent]
]:
"""Load all microagents from the given directory.
Args:
microagent_dir: Path to the microagents directory.
Returns:
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
"""
if isinstance(microagent_dir, str):
microagent_dir = Path(microagent_dir)
repo_agents = {}
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}')
return repo_agents, knowledge_agents, task_agents

View File

@ -0,0 +1,29 @@
from enum import Enum
from pydantic import BaseModel, Field
class MicroAgentType(str, Enum):
"""Type of microagent."""
KNOWLEDGE = 'knowledge'
REPO_KNOWLEDGE = 'repo'
TASK = 'task'
class MicroAgentMetadata(BaseModel):
"""Metadata for all microagents."""
name: str = 'default'
type: MicroAgentType = Field(default=MicroAgentType.KNOWLEDGE)
version: str = Field(default='1.0.0')
agent: str = Field(default='CodeActAgent')
triggers: list[str] = [] # optional, only exists for knowledge microagents
class TaskInput(BaseModel):
"""Input parameter for task-based agents."""
name: str
description: str
required: bool = True

View File

@ -29,11 +29,18 @@ from openhands.events.event import Event
from openhands.events.observation import (
CmdOutputObservation,
ErrorObservation,
FileReadObservation,
NullObservation,
Observation,
UserRejectObservation,
)
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
from openhands.microagent import (
BaseMicroAgent,
KnowledgeMicroAgent,
RepoMicroAgent,
TaskMicroAgent,
)
from openhands.runtime.plugins import (
JupyterRequirement,
PluginRequirement,
@ -219,34 +226,73 @@ class Runtime(FileEditRuntimeMixin):
self.log('info', f'Cloning repo: {selected_repository}')
self.run_action(action)
def get_custom_microagents(self, selected_repository: str | None) -> list[str]:
custom_microagents_content = []
custom_microagents_dir = Path('.openhands') / 'microagents'
dir_name = str(custom_microagents_dir)
def get_microagents_from_selected_repo(
self, selected_repository: str | None
) -> list[BaseMicroAgent]:
loaded_microagents: list[BaseMicroAgent] = []
dir_name = Path('.openhands') / 'microagents'
if selected_repository:
dir_name = str(
Path(selected_repository.split('/')[1]) / custom_microagents_dir
)
dir_name = Path('/workspace') / selected_repository.split('/')[1] / dir_name
# Legacy Repo Instructions
# Check for legacy .openhands_instructions file
obs = self.read(FileReadAction(path='.openhands_instructions'))
if isinstance(obs, ErrorObservation):
self.log('debug', 'openhands_instructions not present')
else:
openhands_instructions = obs.content
self.log('info', f'openhands_instructions: {openhands_instructions}')
custom_microagents_content.append(openhands_instructions)
self.log(
'debug',
f'openhands_instructions not present, trying to load from {dir_name}',
)
obs = self.read(
FileReadAction(path=str(dir_name / '.openhands_instructions'))
)
files = self.list_files(dir_name)
if isinstance(obs, FileReadObservation):
self.log('info', 'openhands_instructions microagent loaded.')
loaded_microagents.append(
BaseMicroAgent.load(
path='.openhands_instructions', file_content=obs.content
)
)
self.log('info', f'Found {len(files)} custom microagents.')
# 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
)
)
for fname in files:
content = self.read(
FileReadAction(path=str(custom_microagents_dir / fname))
).content
custom_microagents_content.append(content)
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
)
)
return custom_microagents_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:
"""Run an action and return the resulting observation.

View File

@ -11,6 +11,7 @@ from openhands.core.schema.agent import AgentState
from openhands.events.action import ChangeAgentStateAction
from openhands.events.event import EventSource
from openhands.events.stream import EventStream
from openhands.microagent import BaseMicroAgent
from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.security import SecurityAnalyzer, options
@ -203,10 +204,10 @@ class AgentSession:
self.runtime.clone_repo(github_token, selected_repository)
if agent.prompt_manager:
microagents = await call_sync_from_async(
self.runtime.get_custom_microagents, selected_repository
microagents: list[BaseMicroAgent] = await call_sync_from_async(
self.runtime.get_microagents_from_selected_repo, selected_repository
)
agent.prompt_manager.load_microagent_files(microagents)
agent.prompt_manager.load_microagents(microagents)
logger.debug(
f'Runtime initialized with plugins: {[plugin.name for plugin in self.runtime.plugins]}'

View File

@ -1,54 +0,0 @@
import os
import frontmatter
import pydantic
class MicroAgentMetadata(pydantic.BaseModel):
name: str = 'default'
agent: str = ''
triggers: list[str] = []
class MicroAgent:
def __init__(self, path: str | None = None, content: str | None = None):
if path and not content:
self.path = path
if not os.path.exists(path):
raise FileNotFoundError(f'Micro agent file {path} is not found')
with open(path, 'r') as file:
loaded = frontmatter.load(file)
self._content = loaded.content
self._metadata = MicroAgentMetadata(**loaded.metadata)
elif content and not path:
metadata, self._content = frontmatter.parse(content)
self._metadata = MicroAgentMetadata(**metadata)
else:
raise Exception('You must pass either path or file content, but not both.')
def get_trigger(self, message: str) -> str | None:
message = message.lower()
for trigger in self.triggers:
if trigger.lower() in message:
return trigger
return None
@property
def content(self) -> str:
return self._content
@property
def metadata(self) -> MicroAgentMetadata:
return self._metadata
@property
def name(self) -> str:
return self._metadata.name
@property
def triggers(self) -> list[str]:
return self._metadata.triggers
@property
def agent(self) -> str:
return self._metadata.agent

View File

@ -5,7 +5,12 @@ from jinja2 import Template
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent
from openhands.microagent import (
BaseMicroAgent,
KnowledgeMicroAgent,
RepoMicroAgent,
load_microagents_from_dir,
)
class PromptManager:
@ -28,31 +33,44 @@ class PromptManager:
microagent_dir: str | None = None,
disabled_microagents: list[str] | None = None,
):
self.disabled_microagents: list[str] = disabled_microagents or []
self.prompt_dir: str = prompt_dir
self.system_template: Template = self._load_template('system_prompt')
self.user_template: Template = self._load_template('user_prompt')
self.microagents: dict = {}
microagent_files = []
self.knowledge_microagents: dict[str, KnowledgeMicroAgent] = {}
self.repo_microagents: dict[str, RepoMicroAgent] = {}
if microagent_dir:
microagent_files = [
os.path.join(microagent_dir, f)
for f in os.listdir(microagent_dir)
if f.endswith('.md')
]
for microagent_file in microagent_files:
microagent = MicroAgent(path=microagent_file)
if (
disabled_microagents is None
or microagent.name not in disabled_microagents
):
self.microagents[microagent.name] = microagent
# Only load KnowledgeMicroAgents
repo_microagents, knowledge_microagents, _ = load_microagents_from_dir(
microagent_dir
)
assert all(
isinstance(microagent, KnowledgeMicroAgent)
for microagent in knowledge_microagents.values()
)
for name, microagent in knowledge_microagents.items():
if name not in self.disabled_microagents:
self.knowledge_microagents[name] = microagent
assert all(
isinstance(microagent, RepoMicroAgent)
for microagent in repo_microagents.values()
)
for name, microagent in repo_microagents.items():
if name not in self.disabled_microagents:
self.repo_microagents[name] = microagent
def load_microagent_files(self, microagent_files: list[str]):
for microagent_file in microagent_files:
microagent = MicroAgent(content=microagent_file)
self.microagents[microagent.name] = microagent
def load_microagents(self, microagents: list[BaseMicroAgent]):
# Only keep KnowledgeMicroAgents and RepoMicroAgents
for microagent in microagents:
if microagent.name in self.disabled_microagents:
continue
if isinstance(microagent, KnowledgeMicroAgent):
self.knowledge_microagents[microagent.name] = microagent
elif isinstance(microagent, RepoMicroAgent):
self.repo_microagents[microagent.name] = microagent
def _load_template(self, template_name: str) -> Template:
if self.prompt_dir is None:
@ -65,7 +83,10 @@ class PromptManager:
def get_system_message(self) -> str:
repo_instructions = ''
for microagent in self.microagents.values():
assert (
len(self.repo_microagents) <= 1
), f'Expecting at most one repo microagent, but found {len(self.repo_microagents)}: {self.repo_microagents.keys()}'
for microagent in self.repo_microagents.values():
# We assume these are the repo instructions
if len(microagent.triggers) == 0:
if repo_instructions:
@ -95,8 +116,8 @@ class PromptManager:
if not message.content:
return
message_content = message.content[0].text
for microagent in self.microagents.values():
trigger = microagent.get_trigger(message_content)
for microagent in self.knowledge_microagents.values():
trigger = microagent.match_trigger(message_content)
if trigger:
micro_text = f'<extra_info>\nThe following information has been included based on a keyword match for "{trigger}". It may or may not be relevant to the user\'s request.'
micro_text += '\n\n' + microagent.content

View File

@ -39,7 +39,8 @@ from openhands.llm.llm import LLM
@pytest.fixture
def agent() -> CodeActAgent:
agent = CodeActAgent(llm=LLM(LLMConfig()), config=AgentConfig())
config = AgentConfig()
agent = CodeActAgent(llm=LLM(LLMConfig()), config=config)
agent.llm = Mock()
agent.llm.config = Mock()
agent.llm.config.max_message_chars = 100

View File

@ -1,31 +1,145 @@
import os
"""Tests for the microagent system."""
from pytest import MonkeyPatch
import tempfile
from pathlib import Path
import openhands.agenthub # noqa: F401
from openhands.utils.microagent import MicroAgent
import pytest
from openhands.core.exceptions import MicroAgentValidationError
from openhands.microagent import (
BaseMicroAgent,
KnowledgeMicroAgent,
MicroAgentMetadata,
MicroAgentType,
RepoMicroAgent,
TaskMicroAgent,
load_microagents_from_dir,
)
CONTENT = (
'# dummy header\n' 'dummy content\n' '## dummy subheader\n' 'dummy subcontent\n'
)
def test_micro_agent_load(tmp_path, monkeypatch: MonkeyPatch):
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
f.write(
(
'---\n'
'name: dummy\n'
'agent: CodeActAgent\n'
'require_env_var:\n'
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
'---\n' + CONTENT
)
)
def test_legacy_micro_agent_load(tmp_path):
"""Test loading of legacy microagents."""
legacy_file = tmp_path / '.openhands_instructions'
legacy_file.write_text(CONTENT)
# Patch the required environment variable
monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value')
micro_agent = BaseMicroAgent.load(legacy_file)
assert isinstance(micro_agent, RepoMicroAgent)
assert micro_agent.name == 'repo_legacy'
assert micro_agent.content == CONTENT
assert micro_agent.type == MicroAgentType.REPO_KNOWLEDGE
micro_agent = MicroAgent(os.path.join(tmp_path, 'dummy.md'))
assert micro_agent is not None
assert micro_agent.content == CONTENT.strip()
@pytest.fixture
def temp_microagents_dir():
"""Create a temporary directory with test microagents."""
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
# Create test knowledge agent
knowledge_agent = """---
name: test_knowledge_agent
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- test
- pytest
---
# Test Guidelines
Testing best practices and guidelines.
"""
(root / '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.
"""
(root / 'repo.md').write_text(repo_agent)
# Create test task agent
task_agent = """---
name: test_task
type: task
version: 1.0.0
agent: CodeActAgent
---
# Test Task
Test task content
"""
(root / 'task.md').write_text(task_agent)
yield root
def test_knowledge_agent():
"""Test knowledge agent functionality."""
agent = KnowledgeMicroAgent(
name='test',
content='Test content',
metadata=MicroAgentMetadata(
name='test', type=MicroAgentType.KNOWLEDGE, triggers=['test', 'pytest']
),
source='test.md',
type=MicroAgentType.KNOWLEDGE,
)
assert agent.match_trigger('running a test') == 'test'
assert agent.match_trigger('using pytest') == 'test'
assert agent.match_trigger('no match here') is None
assert agent.triggers == ['test', 'pytest']
def test_load_microagents(temp_microagents_dir):
"""Test loading microagents from directory."""
repo_agents, knowledge_agents, task_agents = load_microagents_from_dir(
temp_microagents_dir
)
# Check knowledge agents
assert len(knowledge_agents) == 1
agent = knowledge_agents['test_knowledge_agent']
assert isinstance(agent, KnowledgeMicroAgent)
assert 'test' in agent.triggers
# Check repo agents
assert len(repo_agents) == 1
agent = repo_agents['test_repo_agent']
assert isinstance(agent, RepoMicroAgent)
# Check task agents
assert len(task_agents) == 1
agent = task_agents['test_task']
assert isinstance(agent, TaskMicroAgent)
def test_invalid_agent_type(temp_microagents_dir):
"""Test loading agent with invalid type."""
invalid_agent = """---
name: test_invalid
type: invalid
version: 1.0.0
agent: CodeActAgent
---
Invalid agent content
"""
(temp_microagents_dir / 'invalid.md').write_text(invalid_agent)
with pytest.raises(MicroAgentValidationError):
BaseMicroAgent.load(temp_microagents_dir / 'invalid.md')

View File

@ -24,7 +24,8 @@ def mock_llm():
@pytest.fixture
def codeact_agent(mock_llm):
config = AgentConfig()
return CodeActAgent(mock_llm, config)
agent = CodeActAgent(mock_llm, config)
return agent
def response_mock(content: str, tool_call_id: str):

View File

@ -4,7 +4,7 @@ import shutil
import pytest
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent
from openhands.microagent import BaseMicroAgent
from openhands.utils.prompt import PromptManager
@ -24,6 +24,7 @@ def test_prompt_manager_with_microagent(prompt_dir):
microagent_content = """
---
name: flarglebargle
type: knowledge
agent: CodeActAgent
triggers:
- flarglebargle
@ -44,7 +45,8 @@ only respond with a message telling them how smart they are
)
assert manager.prompt_dir == prompt_dir
assert len(manager.microagents) == 1
assert len(manager.repo_microagents) == 0
assert len(manager.knowledge_microagents) == 1
assert isinstance(manager.get_system_message(), str)
assert (
@ -66,7 +68,9 @@ only respond with a message telling them how smart they are
def test_prompt_manager_file_not_found(prompt_dir):
with pytest.raises(FileNotFoundError):
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md'))
BaseMicroAgent.load(
os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md')
)
def test_prompt_manager_template_rendering(prompt_dir):
@ -93,6 +97,7 @@ def test_prompt_manager_disabled_microagents(prompt_dir):
microagent1_content = """
---
name: Test Microagent 1
type: knowledge
agent: CodeActAgent
triggers:
- test1
@ -103,6 +108,7 @@ Test microagent 1 content
microagent2_content = """
---
name: Test Microagent 2
type: knowledge
agent: CodeActAgent
triggers:
- test2
@ -125,9 +131,9 @@ Test microagent 2 content
disabled_microagents=['Test Microagent 1'],
)
assert len(manager.microagents) == 1
assert 'Test Microagent 2' in manager.microagents
assert 'Test Microagent 1' not in manager.microagents
assert len(manager.knowledge_microagents) == 1
assert 'Test Microagent 2' in manager.knowledge_microagents
assert 'Test Microagent 1' not in manager.knowledge_microagents
# Test that all microagents are enabled by default
manager = PromptManager(
@ -135,9 +141,9 @@ Test microagent 2 content
microagent_dir=os.path.join(prompt_dir, 'micro'),
)
assert len(manager.microagents) == 2
assert 'Test Microagent 1' in manager.microagents
assert 'Test Microagent 2' in manager.microagents
assert len(manager.knowledge_microagents) == 2
assert 'Test Microagent 1' in manager.knowledge_microagents
assert 'Test Microagent 2' in manager.knowledge_microagents
# Clean up temporary files
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent1_name}.md'))