Skip to content

Commit b4593eb

Browse files
feat: add configurable marketplace_path setting for public skills loading (#2253)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 87fb615 commit b4593eb

File tree

6 files changed

+165
-22
lines changed

6 files changed

+165
-22
lines changed

openhands-agent-server/openhands/agent_server/skills_router.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
load_all_skills,
1515
sync_public_skills,
1616
)
17+
from openhands.sdk.context.skills.skill import DEFAULT_MARKETPLACE_PATH
1718

1819

1920
skills_router = APIRouter(prefix="/skills", tags=["Skills"])
@@ -63,6 +64,13 @@ class SkillsRequest(BaseModel):
6364
default=True, description="Load project skills from workspace"
6465
)
6566
load_org: bool = Field(default=True, description="Load organization-level skills")
67+
marketplace_path: str | None = Field(
68+
default=DEFAULT_MARKETPLACE_PATH,
69+
description=(
70+
"Relative marketplace JSON path for public skills. "
71+
"Set to null to load all public skills."
72+
),
73+
)
6674
project_dir: str | None = Field(
6775
default=None, description="Workspace directory path for project skills"
6876
)
@@ -145,6 +153,7 @@ def get_skills(request: SkillsRequest) -> SkillsResponse:
145153
org_repo_url=org_repo_url,
146154
org_name=org_name,
147155
sandbox_exposed_urls=sandbox_urls,
156+
marketplace_path=request.marketplace_path,
148157
)
149158

150159
# Convert Skill objects to SkillInfo for response

openhands-agent-server/openhands/agent_server/skills_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
load_available_skills,
2626
)
2727
from openhands.sdk.context.skills.skill import (
28+
DEFAULT_MARKETPLACE_PATH,
2829
PUBLIC_SKILLS_BRANCH,
2930
PUBLIC_SKILLS_REPO,
3031
load_skills_from_dir,
@@ -284,6 +285,7 @@ def load_all_skills(
284285
org_repo_url: str | None = None,
285286
org_name: str | None = None,
286287
sandbox_exposed_urls: list[ExposedUrlData] | None = None,
288+
marketplace_path: str | None = DEFAULT_MARKETPLACE_PATH,
287289
) -> SkillLoadResult:
288290
"""Load and merge skills from all configured sources.
289291
@@ -304,6 +306,8 @@ def load_all_skills(
304306
org_repo_url: Pre-authenticated Git URL for org skills.
305307
org_name: Organization name for org skills.
306308
sandbox_exposed_urls: List of exposed URLs from sandbox.
309+
marketplace_path: Relative marketplace JSON path for public skills.
310+
Pass None to load all public skills without marketplace filtering.
307311
308312
Returns:
309313
SkillLoadResult containing merged skills and source counts.
@@ -326,6 +330,7 @@ def load_all_skills(
326330
include_user=load_user,
327331
include_project=False,
328332
include_public=load_public,
333+
marketplace_path=marketplace_path,
329334
)
330335
sources["sdk_base"] = len(sdk_base)
331336
skill_lists.append(list(sdk_base.values()))

openhands-sdk/openhands/sdk/context/agent_context.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
load_available_skills,
1414
to_prompt,
1515
)
16+
from openhands.sdk.context.skills.skill import DEFAULT_MARKETPLACE_PATH
1617
from openhands.sdk.llm import Message, TextContent
1718
from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec
1819
from openhands.sdk.logger import get_logger
@@ -71,6 +72,13 @@ class AgentContext(BaseModel):
7172
"This allows you to get the latest skills without SDK updates."
7273
),
7374
)
75+
marketplace_path: str | None = Field(
76+
default=DEFAULT_MARKETPLACE_PATH,
77+
description=(
78+
"Relative marketplace JSON path within the public skills repository. "
79+
"Set to None to load all public skills without marketplace filtering."
80+
),
81+
)
7482
secrets: Mapping[str, SecretValue] | None = Field(
7583
default=None,
7684
description=(
@@ -115,6 +123,7 @@ def _load_auto_skills(self):
115123
include_user=self.load_user_skills,
116124
include_project=False,
117125
include_public=self.load_public_skills,
126+
marketplace_path=self.marketplace_path,
118127
)
119128

120129
existing_names = {skill.name for skill in self.skills}

openhands-sdk/openhands/sdk/context/skills/skill.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,7 @@ def load_marketplace_skill_names(
897897
def load_public_skills(
898898
repo_url: str = PUBLIC_SKILLS_REPO,
899899
branch: str = PUBLIC_SKILLS_BRANCH,
900+
marketplace_path: str | None = DEFAULT_MARKETPLACE_PATH,
900901
) -> list[Skill]:
901902
"""Load skills from the public OpenHands skills repository.
902903
@@ -906,9 +907,10 @@ def load_public_skills(
906907
to keep the skills up-to-date. This approach is more efficient than fetching
907908
individual files via HTTP.
908909
909-
Only skills listed in the default marketplace (marketplaces/default.json) are
910-
loaded. This allows the OpenHands extensions repository to contain additional
911-
skills that are not included by default.
910+
By default, only skills listed in the default marketplace
911+
(marketplaces/default.json) are loaded. Pass a different relative
912+
marketplace_path to load another marketplace, or None to load all public
913+
skills without marketplace filtering.
912914
913915
Note: When a skill directory contains a SKILL.md file (AgentSkills format),
914916
any other markdown files in that directory or its subdirectories are treated
@@ -918,6 +920,8 @@ def load_public_skills(
918920
repo_url: URL of the skills repository. Defaults to the official
919921
OpenHands skills repository.
920922
branch: Branch name to load skills from. Defaults to 'main'.
923+
marketplace_path: Relative path to the marketplace JSON file within the
924+
repository. Pass None to load all public skills without filtering.
921925
922926
Returns:
923927
List of Skill objects loaded from the public repository.
@@ -950,34 +954,42 @@ def load_public_skills(
950954
logger.warning(f"Skills directory not found in repository: {skills_dir}")
951955
return all_skills
952956

953-
# Load the default marketplace to determine which skills to include
954-
marketplace_skill_names = load_marketplace_skill_names(
955-
repo_path, DEFAULT_MARKETPLACE_PATH
956-
)
957-
958957
# Determine which skill files to load
958+
if marketplace_path is None:
959+
marketplace_skill_names = None
960+
else:
961+
marketplace_skill_names = load_marketplace_skill_names(
962+
repo_path, marketplace_path
963+
)
964+
if (
965+
marketplace_skill_names is None
966+
and marketplace_path != DEFAULT_MARKETPLACE_PATH
967+
):
968+
logger.warning(
969+
"Configured marketplace path could not be loaded: %s",
970+
marketplace_path,
971+
)
972+
return all_skills
973+
959974
if marketplace_skill_names is not None:
960-
# Marketplace exists: only load skills listed in marketplace
961975
all_skill_files: list[Path] = []
962976
for skill_name in marketplace_skill_names:
963-
# Check for AgentSkills format (directory with SKILL.md)
964977
skill_md = skills_dir / skill_name / "SKILL.md"
965978
if skill_md.exists():
966979
all_skill_files.append(skill_md)
967980
continue
968-
# Check for legacy format (skill_name.md file)
981+
969982
legacy_md = skills_dir / f"{skill_name}.md"
970983
if legacy_md.exists():
971984
all_skill_files.append(legacy_md)
972985
continue
986+
973987
logger.debug(
974-
f"Skill '{skill_name}' from marketplace not found in skills dir"
988+
"Skill '%s' from marketplace '%s' not found in skills dir",
989+
skill_name,
990+
marketplace_path,
975991
)
976992
else:
977-
# No marketplace: load all skills (backward compatible)
978-
# Find SKILL.md directories (AgentSkills format) and regular .md files
979-
# This ensures that markdown files in SKILL.md directories are NOT
980-
# loaded as separate skills - they are reference materials.
981993
skill_md_files = find_skill_md_directories(skills_dir)
982994
skill_md_dirs = {skill_md.parent for skill_md in skill_md_files}
983995
regular_md_files = find_regular_md_files(skills_dir, skill_md_dirs)
@@ -1017,6 +1029,7 @@ def load_available_skills(
10171029
include_user: bool = False,
10181030
include_project: bool = False,
10191031
include_public: bool = False,
1032+
marketplace_path: str | None = DEFAULT_MARKETPLACE_PATH,
10201033
) -> dict[str, Skill]:
10211034
"""Load and merge skills from SDK-level sources with consistent precedence.
10221035
@@ -1033,6 +1046,8 @@ def load_available_skills(
10331046
include_user: Load user-level skills (~/.agents/skills, etc.).
10341047
include_project: Load project-level skills (requires *work_dir*).
10351048
include_public: Load public skills from the OpenHands extensions repo.
1049+
marketplace_path: Relative marketplace JSON path to use for public skills.
1050+
Pass None to load all public skills without marketplace filtering.
10361051
10371052
Returns:
10381053
Dict mapping skill name → Skill, with higher-precedence sources
@@ -1042,7 +1057,7 @@ def load_available_skills(
10421057

10431058
if include_public:
10441059
try:
1045-
for s in load_public_skills():
1060+
for s in load_public_skills(marketplace_path=marketplace_path):
10461061
available[s.name] = s
10471062
except Exception as e:
10481063
logger.warning(f"Failed to load public skills: {e}")

tests/agent_server/test_skills_service.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,24 @@ def test_load_all_skills_sources_tracking(self):
256256
assert result.sources["org"] == 0
257257
assert result.sources["project"] == 0
258258

259+
def test_load_all_skills_passes_marketplace_path_to_sdk_base(self):
260+
"""Test that marketplace_path is forwarded to SDK public skill loading."""
261+
with patch(self._PATCH_TARGET, side_effect=[{}, {}]) as mock_avail:
262+
load_all_skills(
263+
load_public=True,
264+
load_user=True,
265+
load_project=False,
266+
load_org=False,
267+
marketplace_path="marketplaces/custom.json",
268+
)
269+
270+
sdk_base_call = mock_avail.call_args_list[0]
271+
assert sdk_base_call.kwargs["include_public"] is True
272+
assert sdk_base_call.kwargs["marketplace_path"] == "marketplaces/custom.json"
273+
274+
project_call = mock_avail.call_args_list[1]
275+
assert project_call.kwargs["include_public"] is False
276+
259277
def test_load_all_skills_disabled_sources(self):
260278
"""Test that disabled sources are not loaded."""
261279
with patch(self._PATCH_TARGET, return_value={}) as mock_avail:

tests/sdk/context/skill/test_load_public_skills.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,33 @@ def mock_update_repo(repo_url, branch, cache_dir):
386386
assert "testing" in skill_names
387387

388388

389+
def test_agent_context_uses_custom_marketplace_path(
390+
mock_repo_with_marketplace, tmp_path
391+
):
392+
"""Test that AgentContext forwards marketplace_path to public skill loading."""
393+
394+
def mock_update_repo(repo_url, branch, cache_dir):
395+
return mock_repo_with_marketplace
396+
397+
with (
398+
patch(
399+
"openhands.sdk.context.skills.skill.update_skills_repository",
400+
side_effect=mock_update_repo,
401+
),
402+
patch(
403+
"openhands.sdk.context.skills.skill.get_skills_cache_dir",
404+
return_value=tmp_path,
405+
),
406+
):
407+
context = AgentContext(
408+
load_public_skills=True,
409+
marketplace_path="marketplaces/custom.json",
410+
)
411+
412+
skill_names = {s.name for s in context.skills}
413+
assert skill_names == {"git", "internal-only"}
414+
415+
389416
def test_agent_context_can_disable_public_skills_loading():
390417
"""Test that public skills loading can be disabled."""
391418
context = AgentContext(load_public_skills=False)
@@ -626,6 +653,21 @@ def mock_repo_with_marketplace(tmp_path):
626653
}
627654
(marketplaces_dir / "default.json").write_text(json.dumps(marketplace))
628655

656+
custom_marketplace = {
657+
"name": "custom",
658+
"owner": {"name": "OpenHands", "email": "test@test.com"},
659+
"metadata": {"description": "Custom test marketplace", "version": "1.0.0"},
660+
"plugins": [
661+
{"name": "git", "source": "./git", "description": "Git skill"},
662+
{
663+
"name": "internal-only",
664+
"source": "./internal-only",
665+
"description": "Internal skill",
666+
},
667+
],
668+
}
669+
(marketplaces_dir / "custom.json").write_text(json.dumps(custom_marketplace))
670+
629671
# Create .git directory to simulate a git repo
630672
(repo_dir / ".git").mkdir()
631673

@@ -695,11 +737,56 @@ def mock_update_repo(repo_url, branch, cache_dir):
695737
):
696738
skills = load_public_skills()
697739

698-
# Should only have git and docker (from marketplace), not internal-only
699-
skill_names = {s.name for s in skills}
700-
assert skill_names == {"git", "docker"}
701-
assert "internal-only" not in skill_names
702-
assert "experimental" not in skill_names
740+
skill_names = {skill.name for skill in skills}
741+
assert skill_names == {"git", "docker"}
742+
assert "internal-only" not in skill_names
743+
assert "experimental" not in skill_names
744+
745+
746+
def test_load_public_skills_uses_custom_marketplace_path(
747+
mock_repo_with_marketplace, tmp_path
748+
):
749+
"""Test that a custom marketplace_path selects a different skill set."""
750+
751+
def mock_update_repo(repo_url, branch, cache_dir):
752+
return mock_repo_with_marketplace
753+
754+
with (
755+
patch(
756+
"openhands.sdk.context.skills.skill.update_skills_repository",
757+
side_effect=mock_update_repo,
758+
),
759+
patch(
760+
"openhands.sdk.context.skills.skill.get_skills_cache_dir",
761+
return_value=tmp_path,
762+
),
763+
):
764+
skills = load_public_skills(marketplace_path="marketplaces/custom.json")
765+
766+
assert {skill.name for skill in skills} == {"git", "internal-only"}
767+
768+
769+
def test_load_public_skills_returns_empty_for_invalid_custom_marketplace_path(
770+
mock_repo_with_marketplace, tmp_path
771+
):
772+
"""Test that an invalid custom marketplace_path does not broaden skill loading."""
773+
774+
def mock_update_repo(repo_url, branch, cache_dir):
775+
return mock_repo_with_marketplace
776+
777+
with (
778+
patch(
779+
"openhands.sdk.context.skills.skill.update_skills_repository",
780+
side_effect=mock_update_repo,
781+
),
782+
patch(
783+
"openhands.sdk.context.skills.skill.get_skills_cache_dir",
784+
return_value=tmp_path,
785+
),
786+
):
787+
skills = load_public_skills(marketplace_path="marketplaces/missing.json")
788+
789+
assert skills == []
703790

704791

705792
def test_load_public_skills_loads_all_when_no_marketplace(tmp_path):

0 commit comments

Comments
 (0)