Files
OpenHands/openhands/microagent/microagent.py
Engel Nyst 1b63633030 Simplify microagents (#8114)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-04-28 15:00:06 +00:00

191 lines
6.5 KiB
Python

import io
from pathlib import Path
from typing import Union
import frontmatter
from pydantic import BaseModel
from openhands.core.exceptions import (
MicroagentValidationError,
)
from openhands.core.logger import openhands_logger as logger
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],
microagent_dir: Path | None = None,
file_content: str | None = None,
) -> 'BaseMicroagent':
"""Load a microagent from a markdown file with frontmatter.
The agent's name is derived from its path relative to the microagent_dir.
"""
path = Path(path) if isinstance(path, str) else path
# Calculate derived name from relative path if microagent_dir is provided
# Otherwise, we will rely on the name from metadata later
derived_name = None
if microagent_dir is not None:
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
# 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
# Handle case where there's no frontmatter or empty frontmatter
metadata_dict = loaded.metadata or {}
try:
metadata = MicroagentMetadata(**metadata_dict)
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,
}
# Infer the agent type:
# 1. If triggers exist -> KNOWLEDGE
# 2. Else (no triggers) -> REPO
inferred_type: MicroagentType
if metadata.triggers:
inferred_type = MicroagentType.KNOWLEDGE
else:
# No triggers, default to REPO unless metadata explicitly says otherwise (which it shouldn't for REPO)
# This handles cases where 'type' might be missing or defaulted by Pydantic
inferred_type = MicroagentType.REPO_KNOWLEDGE
if inferred_type not in subclass_map:
# This should theoretically not happen with the logic above
raise ValueError(f'Could not determine microagent type for: {path}')
# Use derived_name if available (from relative path), otherwise fallback to metadata.name
agent_name = derived_name if derived_name is not None else metadata.name
agent_class = subclass_map[inferred_type]
return agent_class(
name=agent_name,
content=content,
metadata=metadata,
source=str(path),
type=inferred_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(
f'RepoMicroagent initialized with incorrect type: {self.type}'
)
def load_microagents_from_dir(
microagent_dir: Union[str, Path],
) -> tuple[dict[str, RepoMicroagent], dict[str, KnowledgeMicroagent]]:
"""Load all microagents from the given directory.
Note, legacy repo instructions will not be loaded here.
Args:
microagent_dir: Path to the microagents directory (e.g. .openhands/microagents)
Returns:
Tuple of (repo_agents, knowledge_agents, task_agents) dictionaries
"""
if isinstance(microagent_dir, str):
microagent_dir = Path(microagent_dir)
repo_agents = {}
knowledge_agents = {}
# Load all agents from 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, microagent_dir)
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
knowledge_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