Spaces:
Build error
Build error
import io | |
import re | |
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 InputMetadata, MicroagentMetadata, MicroagentType | |
class BaseMicroagent(BaseModel): | |
"""Base class for all microagents.""" | |
name: str | |
content: str | |
metadata: MicroagentMetadata | |
source: str # path to the file | |
type: MicroagentType | |
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) | |
# Validate MCP tools configuration if present | |
if metadata.mcp_tools: | |
if metadata.mcp_tools.sse_servers: | |
logger.warning( | |
f'Microagent {metadata.name} has SSE servers. Only stdio servers are currently supported.' | |
) | |
if not metadata.mcp_tools.stdio_servers: | |
raise MicroagentValidationError( | |
f'Microagent {metadata.name} has MCP tools configuration but no stdio servers. ' | |
'Only stdio servers are currently supported.' | |
) | |
except Exception as e: | |
# Provide more detailed error message for validation errors | |
error_msg = f'Error validating microagent metadata in {path.name}: {str(e)}' | |
if 'type' in metadata_dict and metadata_dict['type'] not in [ | |
t.value for t in MicroagentType | |
]: | |
valid_types = ', '.join([f'"{t.value}"' for t in MicroagentType]) | |
error_msg += f'. Invalid "type" value: "{metadata_dict["type"]}". Valid types are: {valid_types}' | |
raise MicroagentValidationError(error_msg) from e | |
# Create appropriate subclass based on type | |
subclass_map = { | |
MicroagentType.KNOWLEDGE: KnowledgeMicroagent, | |
MicroagentType.REPO_KNOWLEDGE: RepoMicroagent, | |
MicroagentType.TASK: TaskMicroagent, | |
} | |
# Infer the agent type: | |
# 1. If inputs exist -> TASK | |
# 2. If triggers exist -> KNOWLEDGE | |
# 3. Else (no triggers) -> REPO (always active) | |
inferred_type: MicroagentType | |
if metadata.inputs: | |
inferred_type = MicroagentType.TASK | |
# Add a trigger for the agent name if not already present | |
trigger = f'/{metadata.name}' | |
if not metadata.triggers or trigger not in metadata.triggers: | |
if not metadata.triggers: | |
metadata.triggers = [trigger] | |
else: | |
metadata.triggers.append(trigger) | |
elif metadata.triggers: | |
inferred_type = MicroagentType.KNOWLEDGE | |
else: | |
# No triggers, default to 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 not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]: | |
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK') | |
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 | |
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}' | |
) | |
class TaskMicroagent(KnowledgeMicroagent): | |
"""TaskMicroagent is a special type of KnowledgeMicroagent that requires user input. | |
These microagents are triggered by a special format: "/{agent_name}" | |
and will prompt the user for any required inputs before proceeding. | |
""" | |
def __init__(self, **data): | |
super().__init__(**data) | |
if self.type != MicroagentType.TASK: | |
raise ValueError( | |
f'TaskMicroagent initialized with incorrect type: {self.type}' | |
) | |
# Append a prompt to ask for missing variables | |
self._append_missing_variables_prompt() | |
def _append_missing_variables_prompt(self) -> None: | |
"""Append a prompt to ask for missing variables.""" | |
# Check if the content contains any variables or has inputs defined | |
if not self.requires_user_input() and not self.metadata.inputs: | |
return | |
prompt = "\n\nIf the user didn't provide any of these variables, ask the user to provide them first before the agent can proceed with the task." | |
self.content += prompt | |
def extract_variables(self, content: str) -> list[str]: | |
"""Extract variables from the content. | |
Variables are in the format ${variable_name}. | |
""" | |
pattern = r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}' | |
matches = re.findall(pattern, content) | |
return matches | |
def requires_user_input(self) -> bool: | |
"""Check if this microagent requires user input. | |
Returns True if the content contains variables in the format ${variable_name}. | |
""" | |
# Check if the content contains any variables | |
variables = self.extract_variables(self.content) | |
logger.debug(f'This microagent requires user input: {variables}') | |
return len(variables) > 0 | |
def inputs(self) -> list[InputMetadata]: | |
"""Get the inputs for this microagent.""" | |
return self.metadata.inputs | |
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) 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'): | |
# 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): | |
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents | |
knowledge_agents[agent.name] = agent | |
except MicroagentValidationError as e: | |
# For validation errors, include the original exception | |
error_msg = f'Error loading microagent from {file}: {str(e)}' | |
raise MicroagentValidationError(error_msg) from e | |
except Exception as e: | |
# For other errors, wrap in a ValueError with detailed message | |
error_msg = f'Error loading microagent from {file}: {str(e)}' | |
raise ValueError(error_msg) from e | |
logger.debug( | |
f'Loaded {len(repo_agents) + len(knowledge_agents)} microagents: ' | |
f'{[*repo_agents.keys(), *knowledge_agents.keys()]}' | |
) | |
return repo_agents, knowledge_agents | |