22import re
33from itertools import chain
44from pathlib import Path
5- from typing import Any , Union
5+ from typing import ClassVar , Union
66
77import frontmatter
88from 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
200228class 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
0 commit comments