Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 96 additions & 56 deletions openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,22 +715,57 @@ def load_user_skills() -> list[Skill]:
return all_skills


def _find_git_repo_root(path: Path) -> Path | None:
"""Find the nearest ancestor directory that looks like a Git repository root.

We intentionally don't shell out to `git`, so this works even when git isn't
installed. A directory is considered a git root if it contains a `.git`
entry (directory *or* file, to support worktrees/submodules).
"""

for candidate in (path, *path.parents):
if (candidate / ".git").exists():
return candidate
return None


def _merge_loaded_skills(
*,
source_dir: Path,
loaded_skills: list[dict[str, Skill]],
seen_names: set[str],
all_skills: list[Skill],
) -> None:
for skills_dict in loaded_skills:
for name, skill in skills_dict.items():
if name not in seen_names:
all_skills.append(skill)
seen_names.add(name)
else:
logger.warning(f"Skipping duplicate skill '{name}' from {source_dir}")


def load_project_skills(work_dir: str | Path) -> list[Skill]:
"""Load skills from project-specific directories.

Searches for skills in {work_dir}/.agents/skills/,
{work_dir}/.openhands/skills/, and {work_dir}/.openhands/microagents/
(legacy). Skills are merged in priority order, with earlier directories
taking precedence for duplicate names.
Searches for skills in {work_dir}/.agents/skills/, {work_dir}/.openhands/skills/,
and {work_dir}/.openhands/microagents/ (legacy).

If the working directory is inside a Git repository, this function also loads
skills from the Git repo root, so running from a subdirectory still picks up
repo-level guidance (e.g., AGENTS.md).

Skills are merged in priority order, with the *working directory* taking
precedence over the Git repo root when duplicates exist.

Use .agents/skills for new skills. .openhands/skills is the legacy
OpenHands location, and .openhands/microagents is deprecated.
Use .agents/skills for new skills. .openhands/skills is the legacy OpenHands
location, and .openhands/microagents is deprecated.

Example: If "my-skill" exists in both .agents/skills/ and
.openhands/skills/, the version from .agents/skills/ is used.
Example: If "my-skill" exists in both .agents/skills/ and .openhands/skills/,
the version from .agents/skills/ is used.

Also loads third-party skill files (AGENTS.md, .cursorrules, etc.)
directly from the work directory.
Also loads third-party skill files (AGENTS.md, .cursorrules, etc.) from the
working directory and (if different) the git repo root.

Args:
work_dir: Path to the project/working directory.
Expand All @@ -745,58 +780,63 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
all_skills = []
seen_names: set[str] = set()

# First, load third-party skill files directly from work directory
# This ensures they are loaded even if .openhands/skills doesn't exist
third_party_files = find_third_party_files(
work_dir, Skill.PATH_TO_THIRD_PARTY_SKILL_NAME
)
for path in third_party_files:
try:
skill = Skill.load(path)
if skill.name not in seen_names:
all_skills.append(skill)
seen_names.add(skill.name)
logger.debug(f"Loaded third-party skill: {skill.name} from {path}")
except (SkillError, OSError) as e:
logger.warning(f"Failed to load third-party skill from {path}: {e}")
git_root = _find_git_repo_root(work_dir)

# Working dir takes precedence (more local rules override repo root rules)
search_roots: list[Path] = [work_dir]
if git_root is not None and git_root != work_dir:
search_roots.append(git_root)

# First, load third-party skill files (AGENTS.md, .cursorrules, etc.) from each
# search root. This ensures they are loaded even if .openhands/skills doesn't
# exist.
for root in search_roots:
third_party_files = find_third_party_files(
root, Skill.PATH_TO_THIRD_PARTY_SKILL_NAME
)
for path in third_party_files:
try:
skill = Skill.load(path)
if skill.name not in seen_names:
all_skills.append(skill)
seen_names.add(skill.name)
logger.debug(f"Loaded third-party skill: {skill.name} from {path}")
except (SkillError, OSError) as e:
logger.warning(f"Failed to load third-party skill from {path}: {e}")

# Load project-specific skills from .agents/skills, .openhands/skills,
# and legacy microagents (priority order; first wins for duplicates)
project_skills_dirs = [
work_dir / ".agents" / "skills",
work_dir / ".openhands" / "skills",
work_dir / ".openhands" / "microagents", # Legacy support
]

for project_skills_dir in project_skills_dirs:
if not project_skills_dir.exists():
logger.debug(
f"Project skills directory does not exist: {project_skills_dir}"
)
continue
for root in search_roots:
project_skills_dirs = [
root / ".agents" / "skills",
root / ".openhands" / "skills",
root / ".openhands" / "microagents", # Legacy support
]

for project_skills_dir in project_skills_dirs:
if not project_skills_dir.exists():
logger.debug(
f"Project skills directory does not exist: {project_skills_dir}"
)
continue

try:
logger.debug(f"Loading project skills from {project_skills_dir}")
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
project_skills_dir
)
try:
logger.debug(f"Loading project skills from {project_skills_dir}")
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
project_skills_dir
)

# Merge all skill categories (skip duplicates including third-party)
for skills_dict in [repo_skills, knowledge_skills, agent_skills]:
for name, skill in skills_dict.items():
if name not in seen_names:
all_skills.append(skill)
seen_names.add(name)
else:
logger.warning(
f"Skipping duplicate skill '{name}' from "
f"{project_skills_dir}"
)
_merge_loaded_skills(
source_dir=project_skills_dir,
loaded_skills=[repo_skills, knowledge_skills, agent_skills],
seen_names=seen_names,
all_skills=all_skills,
)

except Exception as e:
logger.warning(
f"Failed to load project skills from {project_skills_dir}: {str(e)}"
)
except Exception as e:
logger.warning(
f"Failed to load project skills from {project_skills_dir}: {str(e)}"
)

logger.debug(
f"Loaded {len(all_skills)} project skills: {[s.name for s in all_skills]}"
Expand Down
46 changes: 46 additions & 0 deletions tests/sdk/context/skill/test_load_project_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,49 @@ def test_load_project_skills_with_string_path(tmp_path):
skills = load_project_skills(str(tmp_path))
assert len(skills) == 1
assert skills[0].name == "test_skill"


def test_load_project_skills_loads_from_git_root_when_called_from_subdir(tmp_path):
"""Running from a subdir should still load repo-level skills (git root)."""
(tmp_path / ".git").mkdir()
(tmp_path / "AGENTS.md").write_text("# Project Guidelines\n\nFrom root")

subdir = tmp_path / "subdir"
subdir.mkdir()

skills = load_project_skills(subdir)
assert any(s.name == "agents" and "From root" in s.content for s in skills)


def test_load_project_skills_workdir_takes_precedence_over_git_root(tmp_path):
"""More local (work dir) skills should override repo root skills."""
(tmp_path / ".git").mkdir()
(tmp_path / "AGENTS.md").write_text("# Project Guidelines\n\nFrom root")

subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "AGENTS.md").write_text("# Project Guidelines\n\nFrom subdir")

skills = load_project_skills(subdir)
agents = [s for s in skills if s.name == "agents"]
assert len(agents) == 1
assert agents[0].content.strip() == "# Project Guidelines\n\nFrom subdir"


def test_load_project_skills_loads_skills_directories_from_git_root(tmp_path):
"""Skills directories (.agents/skills etc.) should be loaded from git root."""
(tmp_path / ".git").mkdir()

skills_dir = tmp_path / ".agents" / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "root_skill.md").write_text(
"---\nname: root_skill\ntriggers:\n - root\n---\nLoaded from root"
)

subdir = tmp_path / "subdir"
subdir.mkdir()

skills = load_project_skills(subdir)
assert any(
s.name == "root_skill" and "Loaded from root" in s.content for s in skills
)
12 changes: 10 additions & 2 deletions tests/sdk/conversation/local/test_conversation_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Core high-level tests for Conversation class focusing on essential
functionality."""

import os
import tempfile
import uuid

Expand Down Expand Up @@ -149,11 +150,18 @@ def test_conversation_event_id_validation():
assert len(our_events) == 1


@pytest.mark.forked # Use pytest-forked to isolate memory-intensive test
def _maybe_forked(test_func):
# pytest-forked doesn't reliably compose with xdist; under xdist we already
# have process isolation per worker, so avoid forking again.
if os.environ.get("PYTEST_XDIST_WORKER"):
return test_func
return pytest.mark.forked(test_func)


@_maybe_forked
def test_conversation_large_event_handling():
"""Test conversation handling of many events with memory usage monitoring."""
import gc
import os

import psutil

Expand Down
71 changes: 71 additions & 0 deletions tests/sdk/conversation/test_repo_root_project_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations
Comment thread
enyst marked this conversation as resolved.

from pathlib import Path

from openhands.sdk.agent import Agent
from openhands.sdk.context.agent_context import AgentContext
from openhands.sdk.context.skills import load_project_skills
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
from openhands.sdk.event import SystemPromptEvent
from openhands.sdk.llm import Message, TextContent
from openhands.sdk.testing import TestLLM


def test_system_prompt_includes_repo_root_agents_md_when_workdir_is_subdir(
tmp_path: Path,
):
"""Repo-root AGENTS.md should still be injected when starting from a subdir.

This is the integration-style equivalent of the CLI manual test:
- work_dir is a subdirectory
- git repo root contains AGENTS.md
- AgentContext is built from load_project_skills(work_dir)
- LocalConversation initialization emits a SystemPromptEvent

We assert the sentinel from the repo root AGENTS.md appears in the
SystemPromptEvent.dynamic_context.
"""

(tmp_path / ".git").mkdir()
(tmp_path / "AGENTS.md").write_text("# Project Guidelines\n\nSENTINEL_ROOT_123\n")

subdir = tmp_path / "subdir"
subdir.mkdir()

skills = load_project_skills(subdir)
ctx = AgentContext(
skills=skills,
# Keep deterministic across environments.
current_datetime="2026-01-01T00:00:00Z",
)

agent = Agent(
llm=TestLLM.from_messages(
[
Message(
role="assistant",
content=[TextContent(text="ok")],
)
],
model="test-model",
),
tools=[],
include_default_tools=[],
agent_context=ctx,
)

conversation = LocalConversation(
agent=agent,
workspace=subdir,
persistence_dir=tmp_path / "conversation",
delete_on_close=True,
)
conversation.send_message("hi")

system_prompt_event = next(
e for e in conversation.state.events if isinstance(e, SystemPromptEvent)
)
assert system_prompt_event.dynamic_context is not None
assert "SENTINEL_ROOT_123" in system_prompt_event.dynamic_context.text

conversation.close()
Loading