Skip to content

Commit 6cfaad8

Browse files
Fix issue #7205: [Feature]: Allow repo microagent Markdown Files Without Required Header (#7655)
Co-authored-by: Engel Nyst <[email protected]>
1 parent a899f80 commit 6cfaad8

File tree

12 files changed

+213
-27
lines changed

12 files changed

+213
-27
lines changed

microagents/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ When OpenHands works with a repository, it:
5151

5252
## Types of MicroAgents
5353

54-
All microagents use markdown files with YAML frontmatter.
54+
Most microagents use markdown files with YAML frontmatter. For repository agents (repo.md), the frontmatter is optional - if not provided, the file will be loaded with default settings as a repository agent.
5555

5656

5757
### 1. Knowledge Agents
@@ -147,6 +147,7 @@ You can see an example of a task-based agent in [OpenHands's pull request updati
147147
- Specify testing and build procedures
148148
- List environment requirements
149149
- Maintain up-to-date team practices
150+
- YAML frontmatter is optional - files without frontmatter will be loaded with default settings
150151

151152
### Submission Process
152153

openhands/integrations/gitlab/gitlab_service.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
from typing import Any
3-
from urllib.parse import quote_plus
43

54
import httpx
65
from pydantic import SecretStr

openhands/memory/memory.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ async def _on_event(self, event: Event):
125125
def _on_workspace_context_recall(
126126
self, event: RecallAction
127127
) -> RecallObservation | None:
128-
"""Add repository and runtime information to the stream as a RecallObservation."""
128+
"""Add repository and runtime information to the stream as a RecallObservation.
129+
130+
This method collects information from all available repo microagents and concatenates their contents.
131+
Multiple repo microagents are supported, and their contents will be concatenated with newlines between them.
132+
"""
129133

130134
# Create WORKSPACE_CONTEXT info:
131135
# - repository_info
@@ -135,11 +139,8 @@ def _on_workspace_context_recall(
135139

136140
# Collect raw repository instructions
137141
repo_instructions = ''
138-
assert (
139-
len(self.repo_microagents) <= 1
140-
), f'Expecting at most one repo microagent, but found {len(self.repo_microagents)}: {self.repo_microagents.keys()}'
141142

142-
# Retrieve the context of repo instructions
143+
# Retrieve the context of repo instructions from all repo microagents
143144
for microagent in self.repo_microagents.values():
144145
if repo_instructions:
145146
repo_instructions += '\n\n'

openhands/microagent/microagent.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,12 @@ def load(
4646
file_io = io.StringIO(file_content)
4747
loaded = frontmatter.load(file_io)
4848
content = loaded.content
49+
50+
# Handle case where there's no frontmatter or empty frontmatter
51+
metadata_dict = loaded.metadata or {}
52+
4953
try:
50-
metadata = MicroAgentMetadata(**loaded.metadata)
54+
metadata = MicroAgentMetadata(**metadata_dict)
5155
except Exception as e:
5256
raise MicroAgentValidationError(f'Error loading metadata: {e}') from e
5357

openhands/microagent/types.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class MicroAgentMetadata(BaseModel):
1515
"""Metadata for all microagents."""
1616

1717
name: str = 'default'
18-
type: MicroAgentType = Field(default=MicroAgentType.KNOWLEDGE)
18+
type: MicroAgentType = Field(default=MicroAgentType.REPO_KNOWLEDGE)
1919
version: str = Field(default='1.0.0')
2020
agent: str = Field(default='CodeActAgent')
2121
triggers: list[str] = [] # optional, only exists for knowledge microagents

openhands/runtime/impl/docker/docker_runtime.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1+
import os
12
from functools import lru_cache
23
from typing import Callable
34
from uuid import UUID
45

5-
import os
6-
76
import docker
87
import httpx
98
import tenacity
@@ -89,9 +88,13 @@ def __init__(
8988
self._vscode_port = -1
9089
self._app_ports: list[int] = []
9190

92-
if os.environ.get("DOCKER_HOST_ADDR"):
93-
logger.info(f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url')
94-
self.config.sandbox.local_runtime_url = f'http://{os.environ["DOCKER_HOST_ADDR"]}'
91+
if os.environ.get('DOCKER_HOST_ADDR'):
92+
logger.info(
93+
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
94+
)
95+
self.config.sandbox.local_runtime_url = (
96+
f'http://{os.environ["DOCKER_HOST_ADDR"]}'
97+
)
9598

9699
self.docker_client: docker.DockerClient = self._init_docker_client()
97100
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'

openhands/server/routes/git.py

-6
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,19 @@
2020
app = APIRouter(prefix='/api/user')
2121

2222

23-
from pydantic import BaseModel
24-
25-
2623
@app.get('/repositories', response_model=list[Repository])
2724
async def get_user_repositories(
2825
sort: str = 'pushed',
2926
installation_id: int | None = None,
3027
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
3128
access_token: SecretStr | None = Depends(get_access_token),
3229
):
33-
3430
if provider_tokens:
3531
client = ProviderHandler(
3632
provider_tokens=provider_tokens, external_auth_token=access_token
3733
)
3834

3935
try:
40-
4136
repos: list[Repository] = await client.get_repositories(
4237
sort, installation_id
4338
)
@@ -135,7 +130,6 @@ async def search_repositories(
135130
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
136131
access_token: SecretStr | None = Depends(get_access_token),
137132
):
138-
139133
if provider_tokens:
140134
client = ProviderHandler(
141135
provider_tokens=provider_tokens, external_auth_token=access_token

openhands/server/session/agent_session.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,9 @@ async def _create_runtime(
324324
return False
325325

326326
if selected_repository and git_provider_tokens:
327-
await self.runtime.clone_repo(git_provider_tokens,
328-
selected_repository,
329-
selected_branch)
327+
await self.runtime.clone_repo(
328+
git_provider_tokens, selected_repository, selected_branch
329+
)
330330
await call_sync_from_async(self.runtime.maybe_run_setup_script)
331331

332332
self.logger.debug(
@@ -408,12 +408,15 @@ async def _create_memory(
408408

409409
# loads microagents from repo/.openhands/microagents
410410
microagents: list[BaseMicroAgent] = await call_sync_from_async(
411-
self.runtime.get_microagents_from_selected_repo, selected_repository.full_name if selected_repository else None
411+
self.runtime.get_microagents_from_selected_repo,
412+
selected_repository.full_name if selected_repository else None,
412413
)
413414
memory.load_user_workspace_microagents(microagents)
414415

415416
if selected_repository and repo_directory:
416-
memory.set_repository_info(selected_repository.full_name, repo_directory)
417+
memory.set_repository_info(
418+
selected_repository.full_name, repo_directory
419+
)
417420
return memory
418421

419422
def _maybe_restore_state(self) -> State | None:

poetry.lock

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ reportlab = "*"
9999
[tool.coverage.run]
100100
concurrency = ["gevent"]
101101

102+
102103
[tool.poetry.group.runtime.dependencies]
103104
jupyterlab = "*"
104105
notebook = "*"
@@ -127,6 +128,7 @@ ignore = ["D1"]
127128
[tool.ruff.lint.pydocstyle]
128129
convention = "google"
129130

131+
130132
[tool.poetry.group.evaluation.dependencies]
131133
streamlit = "*"
132134
whatthepatch = "*"

tests/unit/test_memory.py

+85
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,88 @@ def original_add_event(event, source):
319319
assert observation.microagent_knowledge[0].name == 'flarglebargle'
320320
assert observation.microagent_knowledge[0].trigger == 'flarglebargle'
321321
assert 'magic word' in observation.microagent_knowledge[0].content
322+
323+
324+
def test_memory_multiple_repo_microagents(prompt_dir):
325+
"""Test that Memory loads and concatenates multiple repo microagents correctly."""
326+
# Create an in-memory file store and real event stream
327+
file_store = InMemoryFileStore()
328+
event_stream = EventStream(sid='test-session', file_store=file_store)
329+
330+
# Create two test repo microagents
331+
repo_microagent1_name = 'test_repo_microagent1'
332+
repo_microagent1_content = """---
333+
REPOSITORY INSTRUCTIONS: This is the first test repository.
334+
"""
335+
336+
repo_microagent2_name = 'test_repo_microagent2'
337+
repo_microagent2_content = """---
338+
name: test_repo2
339+
type: repo
340+
agent: CodeActAgent
341+
---
342+
343+
REPOSITORY INSTRUCTIONS: This is the second test repository.
344+
"""
345+
346+
# Create temporary repo microagent files
347+
os.makedirs(os.path.join(prompt_dir, 'micro'), exist_ok=True)
348+
with open(
349+
os.path.join(prompt_dir, 'micro', f'{repo_microagent1_name}.md'), 'w'
350+
) as f:
351+
f.write(repo_microagent1_content)
352+
353+
with open(
354+
os.path.join(prompt_dir, 'micro', f'{repo_microagent2_name}.md'), 'w'
355+
) as f:
356+
f.write(repo_microagent2_content)
357+
358+
# Patch the global microagents directory to use our test directory
359+
test_microagents_dir = os.path.join(prompt_dir, 'micro')
360+
with patch('openhands.memory.memory.GLOBAL_MICROAGENTS_DIR', test_microagents_dir):
361+
# Initialize Memory
362+
memory = Memory(
363+
event_stream=event_stream,
364+
sid='test-session',
365+
)
366+
367+
# Set repository info
368+
memory.set_repository_info('owner/repo', '/workspace/repo')
369+
370+
# Create and add the first user message
371+
user_message = MessageAction(content='First user message')
372+
user_message._source = EventSource.USER # type: ignore[attr-defined]
373+
event_stream.add_event(user_message, EventSource.USER)
374+
375+
# Create and add the microagent action
376+
microagent_action = RecallAction(
377+
query='First user message', recall_type=RecallType.WORKSPACE_CONTEXT
378+
)
379+
microagent_action._source = EventSource.USER # type: ignore[attr-defined]
380+
event_stream.add_event(microagent_action, EventSource.USER)
381+
382+
# Give it a little time to process
383+
time.sleep(0.3)
384+
385+
# Get all events from the stream
386+
events = list(event_stream.get_events())
387+
388+
# Find the RecallObservation event
389+
microagent_obs_events = [
390+
event for event in events if isinstance(event, RecallObservation)
391+
]
392+
393+
# We should have one RecallObservation
394+
assert len(microagent_obs_events) > 0
395+
396+
# Get the first RecallObservation
397+
observation = microagent_obs_events[0]
398+
assert observation.recall_type == RecallType.WORKSPACE_CONTEXT
399+
assert observation.repo_name == 'owner/repo'
400+
assert observation.repo_directory == '/workspace/repo'
401+
assert 'This is the first test repository' in observation.repo_instructions
402+
assert 'This is the second test repository' in observation.repo_instructions
403+
404+
# Clean up
405+
os.remove(os.path.join(prompt_dir, 'micro', f'{repo_microagent1_name}.md'))
406+
os.remove(os.path.join(prompt_dir, 'micro', f'{repo_microagent2_name}.md'))
+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from pathlib import Path
2+
3+
from openhands.microagent.microagent import BaseMicroAgent, RepoMicroAgent
4+
from openhands.microagent.types import MicroAgentType
5+
6+
7+
def test_load_markdown_without_frontmatter():
8+
"""Test loading a markdown file without frontmatter."""
9+
content = '# Test Content\nThis is a test markdown file without frontmatter.'
10+
path = Path('test.md')
11+
12+
# Load the agent from content
13+
agent = BaseMicroAgent.load(path, content)
14+
15+
# Verify it's loaded as a repo agent with default values
16+
assert isinstance(agent, RepoMicroAgent)
17+
assert agent.name == 'default'
18+
assert agent.content == content
19+
assert agent.type == MicroAgentType.REPO_KNOWLEDGE
20+
assert agent.metadata.agent == 'CodeActAgent'
21+
assert agent.metadata.version == '1.0.0'
22+
23+
24+
def test_load_markdown_with_empty_frontmatter():
25+
"""Test loading a markdown file with empty frontmatter."""
26+
content = (
27+
'---\n---\n# Test Content\nThis is a test markdown file with empty frontmatter.'
28+
)
29+
path = Path('test.md')
30+
31+
# Load the agent from content
32+
agent = BaseMicroAgent.load(path, content)
33+
34+
# Verify it's loaded as a repo agent with default values
35+
assert isinstance(agent, RepoMicroAgent)
36+
assert agent.name == 'default'
37+
assert (
38+
agent.content
39+
== '# Test Content\nThis is a test markdown file with empty frontmatter.'
40+
)
41+
assert agent.type == MicroAgentType.REPO_KNOWLEDGE
42+
assert agent.metadata.agent == 'CodeActAgent'
43+
assert agent.metadata.version == '1.0.0'
44+
45+
46+
def test_load_markdown_with_partial_frontmatter():
47+
"""Test loading a markdown file with partial frontmatter."""
48+
content = """---
49+
name: custom_name
50+
---
51+
# Test Content
52+
This is a test markdown file with partial frontmatter."""
53+
path = Path('test.md')
54+
55+
# Load the agent from content
56+
agent = BaseMicroAgent.load(path, content)
57+
58+
# Verify it uses provided name but default values for other fields
59+
assert isinstance(agent, RepoMicroAgent)
60+
assert agent.name == 'custom_name'
61+
assert (
62+
agent.content
63+
== '# Test Content\nThis is a test markdown file with partial frontmatter.'
64+
)
65+
assert agent.type == MicroAgentType.REPO_KNOWLEDGE
66+
assert agent.metadata.agent == 'CodeActAgent'
67+
assert agent.metadata.version == '1.0.0'
68+
69+
70+
def test_load_markdown_with_full_frontmatter():
71+
"""Test loading a markdown file with full frontmatter still works."""
72+
content = """---
73+
name: test_agent
74+
type: repo
75+
agent: CustomAgent
76+
version: 2.0.0
77+
---
78+
# Test Content
79+
This is a test markdown file with full frontmatter."""
80+
path = Path('test.md')
81+
82+
# Load the agent from content
83+
agent = BaseMicroAgent.load(path, content)
84+
85+
# Verify all provided values are used
86+
assert isinstance(agent, RepoMicroAgent)
87+
assert agent.name == 'test_agent'
88+
assert (
89+
agent.content
90+
== '# Test Content\nThis is a test markdown file with full frontmatter.'
91+
)
92+
assert agent.type == MicroAgentType.REPO_KNOWLEDGE
93+
assert agent.metadata.agent == 'CustomAgent'
94+
assert agent.metadata.version == '2.0.0'

0 commit comments

Comments
 (0)