Skip to content
Draft
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
65 changes: 64 additions & 1 deletion openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,44 @@ class Skill(BaseModel):
"gemini.md": "gemini",
}

@classmethod
def should_include_for_model(
cls,
skill_name: str,
llm_model: str | None = None,
llm_model_canonical: str | None = None,
) -> bool:
"""Decide if a third-party repo skill should be included for a model.

Only applies to vendor-specific files (e.g., "claude", "gemini"). If
no model information is provided, return True (backward-compatible).
"""
if not llm_model and not llm_model_canonical:
return True

def _is_vendor(model: str | None, vendor: str) -> bool:
if not model:
return False
ml = model.lower()
if vendor == "claude":
return "claude" in ml or "anthropic" in ml
if vendor == "gemini":
return "gemini" in ml
return vendor in ml

is_claude = _is_vendor(llm_model, "claude") or _is_vendor(
llm_model_canonical, "claude"
)
is_gemini = _is_vendor(llm_model, "gemini") or _is_vendor(
llm_model_canonical, "gemini"
)

if skill_name == "claude":
return is_claude
if skill_name == "gemini":
return is_gemini
return True

@classmethod
def _handle_third_party(cls, path: Path, file_content: str) -> Union["Skill", None]:
# Determine the agent name based on file type
Expand Down Expand Up @@ -285,13 +323,17 @@ def requires_user_input(self) -> bool:

def load_skills_from_dir(
skill_dir: str | Path,
llm_model: str | None = None,
llm_model_canonical: str | None = None,
) -> tuple[dict[str, Skill], dict[str, Skill]]:
"""Load all skills from the given directory.

Note, legacy repo instructions will not be loaded here.

Args:
skill_dir: Path to the skills directory (e.g. .openhands/skills)
llm_model: Optional current model identifier for vendor-gating.
llm_model_canonical: Optional canonical model name for vendor-gating.

Returns:
Tuple of (repo_skills, knowledge_skills) dictionaries.
Expand Down Expand Up @@ -326,7 +368,26 @@ def load_skills_from_dir(
# Process all files in one loop
for file in chain(special_files, md_files):
try:
skill = Skill.load(file, skill_dir)
# Vendor gating for third-party files based on filename
fname = file.name.lower()
vendor_skill_name = Skill.PATH_TO_THIRD_PARTY_SKILL_NAME.get(fname)
if vendor_skill_name in (
"claude",
"gemini",
) and not Skill.should_include_for_model(
vendor_skill_name,
llm_model=llm_model,
llm_model_canonical=llm_model_canonical,
):
logger.info(
f"Skipping vendor-specific instructions from {file} for model"
)
continue

skill = Skill.load(
file,
skill_dir,
)
if skill.trigger is None:
repo_skills[skill.name] = skill
else:
Expand Down Expand Up @@ -554,6 +615,8 @@ def load_public_skills(
path=skill_file,
skill_dir=repo_path,
)
if skill is None:
continue
all_skills.append(skill)
logger.debug(f"Loaded public skill: {skill.name}")
except Exception as e:
Expand Down
67 changes: 67 additions & 0 deletions tests/sdk/context/test_agent_context_model_specific.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import tempfile
from pathlib import Path

from openhands.sdk.context.skills import load_skills_from_dir


def _write_repo_with_vendor_files(root: Path):
# repo skill under .openhands/skills/repo.md
skills_dir = root / ".openhands" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
repo_text = (
"---\n# type: repo\nversion: 1.0.0\nagent: CodeActAgent\n---\n\nRepo baseline\n"
)
(skills_dir / "repo.md").write_text(repo_text)

# vendor files in repo root
(root / "claude.md").write_text("Claude-Specific Instructions")
(root / "gemini.md").write_text("Gemini-Specific Instructions")

return skills_dir


def test_loader_gates_claude_vendor_file():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
skills_dir = _write_repo_with_vendor_files(root)
repo_skills, _ = load_skills_from_dir(
skills_dir, llm_model="litellm_proxy/anthropic/claude-sonnet-4"
)
assert "repo" in repo_skills
assert "claude" in repo_skills
assert "gemini" not in repo_skills


def test_loader_gates_gemini_vendor_file():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
skills_dir = _write_repo_with_vendor_files(root)
repo_skills, _ = load_skills_from_dir(skills_dir, llm_model="gemini-2.5-pro")
assert "repo" in repo_skills
assert "gemini" in repo_skills
assert "claude" not in repo_skills


def test_loader_excludes_both_for_other_models():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
skills_dir = _write_repo_with_vendor_files(root)
repo_skills, _ = load_skills_from_dir(skills_dir, llm_model="openai/gpt-4o")
assert "repo" in repo_skills
assert "claude" not in repo_skills
assert "gemini" not in repo_skills


def test_loader_uses_canonical_name_for_vendor_match():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
skills_dir = _write_repo_with_vendor_files(root)
# Non-matching "proxy" model, but canonical matches Anthropic/Claude
repo_skills, _ = load_skills_from_dir(
skills_dir,
llm_model="proxy/test-model",
llm_model_canonical="anthropic/claude-sonnet-4",
)
assert "repo" in repo_skills
assert "claude" in repo_skills
assert "gemini" not in repo_skills
Loading