Skip to content

Commit be077c4

Browse files
committed
port over microagent changes
1 parent 205f2af commit be077c4

File tree

2 files changed

+107
-61
lines changed

2 files changed

+107
-61
lines changed

openhands/core/context/microagents/microagent.py

Lines changed: 107 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
from itertools import chain
44
from pathlib import Path
5-
from typing import Any, Union
5+
from typing import ClassVar, Union
66

77
import frontmatter
88
from pydantic import BaseModel
@@ -25,13 +25,38 @@ class BaseMicroagent(BaseModel):
2525
source: str # path to the file
2626
type: MicroagentType
2727

28+
PATH_TO_THIRD_PARTY_MICROAGENT_NAME: ClassVar[dict[str, str]] = {
29+
'.cursorrules': 'cursorrules',
30+
'agents.md': 'agents',
31+
'agent.md': 'agents',
32+
}
33+
34+
@classmethod
35+
def _handle_third_party(
36+
cls, path: Path, file_content: str
37+
) -> Union['RepoMicroagent', None]:
38+
# Determine the agent name based on file type
39+
microagent_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(path.name.lower())
40+
41+
# Create RepoMicroagent if we recognized the file type
42+
if microagent_name is not None:
43+
return RepoMicroagent(
44+
name=microagent_name,
45+
content=file_content,
46+
metadata=MicroagentMetadata(name=microagent_name),
47+
source=str(path),
48+
type=MicroagentType.REPO_KNOWLEDGE,
49+
)
50+
51+
return None
52+
2853
@classmethod
2954
def load(
3055
cls,
3156
path: Union[str, Path],
3257
microagent_dir: Path | None = None,
3358
file_content: str | None = None,
34-
) -> "BaseMicroagent":
59+
) -> 'BaseMicroagent':
3560
"""Load a microagent from a markdown file with frontmatter.
3661
3762
The agent's name is derived from its path relative to the microagent_dir.
@@ -42,63 +67,64 @@ def load(
4267
# Otherwise, we will rely on the name from metadata later
4368
derived_name = None
4469
if microagent_dir is not None:
45-
# Special handling for .cursorrules files which are not in microagent_dir
46-
if path.name == ".cursorrules":
47-
derived_name = "cursorrules"
48-
else:
49-
derived_name = str(path.relative_to(microagent_dir).with_suffix(""))
70+
# Special handling for files which are not in microagent_dir
71+
derived_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(
72+
path.name.lower()
73+
) or str(path.relative_to(microagent_dir).with_suffix(''))
5074

5175
# Only load directly from path if file_content is not provided
5276
if file_content is None:
5377
with open(path) as f:
5478
file_content = f.read()
5579

5680
# Legacy repo instructions are stored in .openhands_instructions
57-
if path.name == ".openhands_instructions":
81+
if path.name == '.openhands_instructions':
5882
return RepoMicroagent(
59-
name="repo_legacy",
83+
name='repo_legacy',
6084
content=file_content,
61-
metadata=MicroagentMetadata(name="repo_legacy"),
85+
metadata=MicroagentMetadata(name='repo_legacy'),
6286
source=str(path),
6387
type=MicroagentType.REPO_KNOWLEDGE,
6488
)
6589

66-
# Handle .cursorrules files
67-
if path.name == ".cursorrules":
68-
return RepoMicroagent(
69-
name="cursorrules",
70-
content=file_content,
71-
metadata=MicroagentMetadata(name="cursorrules"),
72-
source=str(path),
73-
type=MicroagentType.REPO_KNOWLEDGE,
74-
)
90+
# Handle third-party agent instruction files
91+
third_party_agent = cls._handle_third_party(path, file_content)
92+
if third_party_agent is not None:
93+
return third_party_agent
7594

7695
file_io = io.StringIO(file_content)
7796
loaded = frontmatter.load(file_io)
7897
content = loaded.content
7998

8099
# Handle case where there's no frontmatter or empty frontmatter
81-
metadata_dict: dict[str, Any] = loaded.metadata or {}
100+
metadata_dict = loaded.metadata or {}
82101

83102
# Ensure version is always a string (YAML may parse numeric versions as integers)
84-
if "version" in metadata_dict and not isinstance(metadata_dict["version"], str):
85-
metadata_dict["version"] = str(metadata_dict["version"])
103+
if 'version' in metadata_dict and not isinstance(metadata_dict['version'], str):
104+
metadata_dict['version'] = str(metadata_dict['version'])
86105

87106
try:
88107
metadata = MicroagentMetadata.model_validate(metadata_dict)
89108

90109
# Validate MCP tools configuration if present
91110
if metadata.mcp_tools:
92111
if metadata.mcp_tools.sse_servers:
93-
logger.warning(f"Microagent {metadata.name} has SSE servers. Only stdio servers are currently supported.")
112+
logger.warning(
113+
f'Microagent {metadata.name} has SSE servers. Only stdio servers are currently supported.'
114+
)
94115

95116
if not metadata.mcp_tools.stdio_servers:
96-
raise MicroagentValidationError(f"Microagent {metadata.name} has MCP tools configuration but no stdio servers. Only stdio servers are currently supported.")
117+
raise MicroagentValidationError(
118+
f'Microagent {metadata.name} has MCP tools configuration but no stdio servers. '
119+
'Only stdio servers are currently supported.'
120+
)
97121
except Exception as e:
98122
# Provide more detailed error message for validation errors
99-
error_msg = f"Error validating microagent metadata in {path.name}: {str(e)}"
100-
if "type" in metadata_dict and metadata_dict["type"] not in [t.value for t in MicroagentType]:
101-
valid_types = ", ".join([f'"{t.value}"' for t in MicroagentType])
123+
error_msg = f'Error validating microagent metadata in {path.name}: {str(e)}'
124+
if 'type' in metadata_dict and metadata_dict['type'] not in [
125+
t.value for t in MicroagentType
126+
]:
127+
valid_types = ', '.join([f'"{t.value}"' for t in MicroagentType])
102128
error_msg += f'. Invalid "type" value: "{metadata_dict["type"]}". Valid types are: {valid_types}'
103129
raise MicroagentValidationError(error_msg) from e
104130

@@ -117,7 +143,7 @@ def load(
117143
if metadata.inputs:
118144
inferred_type = MicroagentType.TASK
119145
# Add a trigger for the agent name if not already present
120-
trigger = f"/{metadata.name}"
146+
trigger = f'/{metadata.name}'
121147
if not metadata.triggers or trigger not in metadata.triggers:
122148
if not metadata.triggers:
123149
metadata.triggers = [trigger]
@@ -132,7 +158,7 @@ def load(
132158

133159
if inferred_type not in subclass_map:
134160
# This should theoretically not happen with the logic above
135-
raise ValueError(f"Could not determine microagent type for: {path}")
161+
raise ValueError(f'Could not determine microagent type for: {path}')
136162

137163
# Use derived_name if available (from relative path), otherwise fallback to metadata.name
138164
agent_name = derived_name if derived_name is not None else metadata.name
@@ -160,7 +186,7 @@ class KnowledgeMicroagent(BaseMicroagent):
160186
def __init__(self, **data):
161187
super().__init__(**data)
162188
if self.type not in [MicroagentType.KNOWLEDGE, MicroagentType.TASK]:
163-
raise ValueError("KnowledgeMicroagent must have type KNOWLEDGE or TASK")
189+
raise ValueError('KnowledgeMicroagent must have type KNOWLEDGE or TASK')
164190

165191
def match_trigger(self, message: str) -> str | None:
166192
"""Match a trigger in the message.
@@ -194,7 +220,9 @@ class RepoMicroagent(BaseMicroagent):
194220
def __init__(self, **data):
195221
super().__init__(**data)
196222
if self.type != MicroagentType.REPO_KNOWLEDGE:
197-
raise ValueError(f"RepoMicroagent initialized with incorrect type: {self.type}")
223+
raise ValueError(
224+
f'RepoMicroagent initialized with incorrect type: {self.type}'
225+
)
198226

199227

200228
class TaskMicroagent(KnowledgeMicroagent):
@@ -207,7 +235,9 @@ class TaskMicroagent(KnowledgeMicroagent):
207235
def __init__(self, **data):
208236
super().__init__(**data)
209237
if self.type != MicroagentType.TASK:
210-
raise ValueError(f"TaskMicroagent initialized with incorrect type: {self.type}")
238+
raise ValueError(
239+
f'TaskMicroagent initialized with incorrect type: {self.type}'
240+
)
211241

212242
# Append a prompt to ask for missing variables
213243
self._append_missing_variables_prompt()
@@ -226,7 +256,7 @@ def extract_variables(self, content: str) -> list[str]:
226256
227257
Variables are in the format ${variable_name}.
228258
"""
229-
pattern = r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
259+
pattern = r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}'
230260
matches = re.findall(pattern, content)
231261
return matches
232262

@@ -237,7 +267,7 @@ def requires_user_input(self) -> bool:
237267
"""
238268
# Check if the content contains any variables
239269
variables = self.extract_variables(self.content)
240-
logger.debug(f"This microagent requires user input: {variables}")
270+
logger.debug(f'This microagent requires user input: {variables}')
241271
return len(variables) > 0
242272

243273
@property
@@ -266,32 +296,48 @@ def load_microagents_from_dir(
266296
knowledge_agents = {}
267297

268298
# Load all agents from microagents directory
269-
logger.debug(f"Loading agents from {microagent_dir}")
299+
logger.debug(f'Loading agents from {microagent_dir}')
300+
301+
# Always check for .cursorrules and AGENTS.md files in repo root, regardless of whether microagents_dir exists
302+
special_files = []
303+
repo_root = microagent_dir.parent.parent
304+
305+
# Check for .cursorrules
306+
if (repo_root / '.cursorrules').exists():
307+
special_files.append(repo_root / '.cursorrules')
308+
309+
# Check for AGENTS.md (case-insensitive)
310+
for agents_filename in ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']:
311+
agents_path = repo_root / agents_filename
312+
if agents_path.exists():
313+
special_files.append(agents_path)
314+
break # Only add the first one found to avoid duplicates
315+
316+
# Collect .md files from microagents directory if it exists
317+
md_files = []
270318
if microagent_dir.exists():
271-
# Collect .cursorrules file from repo root and .md files from microagents dir
272-
cursorrules_files = []
273-
if (microagent_dir.parent.parent / ".cursorrules").exists():
274-
cursorrules_files = [microagent_dir.parent.parent / ".cursorrules"]
275-
276-
md_files = [f for f in microagent_dir.rglob("*.md") if f.name != "README.md"]
277-
278-
# Process all files in one loop
279-
for file in chain(cursorrules_files, md_files):
280-
try:
281-
agent = BaseMicroagent.load(file, microagent_dir)
282-
if isinstance(agent, RepoMicroagent):
283-
repo_agents[agent.name] = agent
284-
elif isinstance(agent, KnowledgeMicroagent):
285-
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
286-
knowledge_agents[agent.name] = agent
287-
except MicroagentValidationError as e:
288-
# For validation errors, include the original exception
289-
error_msg = f"Error loading microagent from {file}: {str(e)}"
290-
raise MicroagentValidationError(error_msg) from e
291-
except Exception as e:
292-
# For other errors, wrap in a ValueError with detailed message
293-
error_msg = f"Error loading microagent from {file}: {str(e)}"
294-
raise ValueError(error_msg) from e
295-
296-
logger.debug(f"Loaded {len(repo_agents) + len(knowledge_agents)} microagents: {[*repo_agents.keys(), *knowledge_agents.keys()]}")
319+
md_files = [f for f in microagent_dir.rglob('*.md') if f.name != 'README.md']
320+
321+
# Process all files in one loop
322+
for file in chain(special_files, md_files):
323+
try:
324+
agent = BaseMicroagent.load(file, microagent_dir)
325+
if isinstance(agent, RepoMicroagent):
326+
repo_agents[agent.name] = agent
327+
elif isinstance(agent, KnowledgeMicroagent):
328+
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
329+
knowledge_agents[agent.name] = agent
330+
except MicroagentValidationError as e:
331+
# For validation errors, include the original exception
332+
error_msg = f'Error loading microagent from {file}: {str(e)}'
333+
raise MicroagentValidationError(error_msg) from e
334+
except Exception as e:
335+
# For other errors, wrap in a ValueError with detailed message
336+
error_msg = f'Error loading microagent from {file}: {str(e)}'
337+
raise ValueError(error_msg) from e
338+
339+
logger.debug(
340+
f'Loaded {len(repo_agents) + len(knowledge_agents)} microagents: '
341+
f'{[*repo_agents.keys(), *knowledge_agents.keys()]}'
342+
)
297343
return repo_agents, knowledge_agents

openhands/core/context/prompt_extension

Whitespace-only changes.

0 commit comments

Comments
 (0)