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
28 changes: 22 additions & 6 deletions openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Skill,
SkillKnowledge,
load_available_skills,
merge_skills_by_name,
to_prompt,
)
from openhands.sdk.skills.skill import DEFAULT_MARKETPLACE_PATH
Expand Down Expand Up @@ -96,6 +97,22 @@ class AgentContext(BaseModel):
),
json_schema_extra={"acp_compatible": True},
)
load_project_skills: bool = Field(
default=False,
description=(
"Whether to automatically load project skills from the conversation "
"workspace (e.g. .openhands/skills/, AGENTS.md). Unlike "
Comment thread
VascoSch92 marked this conversation as resolved.
"load_user_skills / load_public_skills, this flag is not resolved by "
"AgentContext itself (the workspace path is unknown at validation "
"time); LocalConversation resolves it lazily on the first "
"send_message() / run(), when the workspace is known. Also unlike "
"load_user_skills / load_public_skills (which yield to explicit "
"skills on a name conflict), resolved project skills are "
"authoritative: a project skill overrides a same-named skill already "
"present in `skills`."
),
json_schema_extra={"acp_compatible": True},
)
secrets: Mapping[str, SecretValue] | None = Field(
default=None,
description=(
Expand Down Expand Up @@ -160,15 +177,14 @@ def _load_auto_skills(self):
marketplace_path=self.marketplace_path,
)

existing_names = {skill.name for skill in self.skills}
for name, skill in auto_skills.items():
if name not in existing_names:
self.skills.append(skill)
else:
# Explicit skills are authoritative; auto-loaded skills only fill gaps.
explicit_names = {skill.name for skill in self.skills}
for name in auto_skills:
if name in explicit_names:
logger.debug(
f"Skipping auto-loaded skill '{name}' (already in explicit skills)"
)

self.skills = merge_skills_by_name(self.skills, auto_skills.values())
Comment thread
VascoSch92 marked this conversation as resolved.
return self

def get_secret_infos(self) -> list[dict[str, str | None]]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from openhands.sdk.security.confirmation_policy import (
ConfirmationPolicyBase,
)
from openhands.sdk.skills import load_available_skills, merge_skills_by_name
from openhands.sdk.skills.utils import expand_mcp_variables
from openhands.sdk.subagent import (
AgentDefinition,
Expand Down Expand Up @@ -504,6 +505,38 @@ def _ensure_plugins_loaded(self) -> None:

logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation")

# Resolve project skills from the workspace. AgentContext can't do this
# itself (the workspace path is unknown at validation time), so it is done
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m a bit surprised about this… is there a reason why this worked on app-server and not in agent-canvas? I thought app-server uses /skills and loads these too? Or did we change how loading skills works?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands investigate (clone yourself agent-canvas and openhands/openhands too) and respond directly in this thread please.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh oh! There was an unexpected error starting the job :(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands investigate (clone yourself agent-canvas and openhands/openhands too) and respond directly in this thread please.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! enyst can track my progress at all-hands.dev

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenHands encountered an error: Request timeout after 30 seconds to https://lzjukjubtpfwagon.prod-runtime.all-hands.dev/api/conversations/321822f7-47bf-4548-9bc0-fc102f24ac04/ask_agent

See the conversation for more information.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exact SDK agent-server code path I meant is here:

  • /api/skills route forwards the request flags and project_dir into load_all_skills(...):
    # Call the service
    result = load_all_skills(
    load_public=request.load_public,
    load_user=request.load_user,
    load_project=request.load_project,
    load_org=request.load_org,
    project_dir=request.project_dir,
    org_repo_url=org_repo_url,
    org_name=org_name,
    sandbox_exposed_urls=sandbox_urls,
  • load_all_skills(...) then loads project skills specifically with work_dir=project_dir and include_project=load_project:
    # 5. Load project skills (highest precedence)
    project_skills = load_available_skills(
    work_dir=project_dir if load_project else None,
    include_user=False,
    include_project=load_project,
    include_public=False,
    )
    sources["project"] = len(project_skills)
    skill_lists.append(list(project_skills.values()))
  • That calls the SDK helper branch here (load_available_skillsload_project_skills(work_dir)):
    if include_project and work_dir:
    try:
    for s in load_project_skills(work_dir):
    available[s.name] = s
    except Exception as e:
    logger.warning(f"Failed to load project skills: {e}")
  • And load_project_skills(...) is the function that scans the actual project locations (.agents/skills, legacy .openhands/skills, .openhands/microagents, plus AGENTS.md/.cursorrules-style files):
    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).
    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.
    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.) from the
    working directory and (if different) the git repo root.
    Args:
    work_dir: Path to the project/working directory.
    Returns:
    List of Skill objects loaded from project directories.
    Returns empty list if no skills found or loading fails.
    """
    if isinstance(work_dir, str):
    work_dir = Path(work_dir)
    all_skills = []
    seen_names: set[str] = set()
    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, yaml.YAMLError) 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)
    for root in search_roots:
    project_skills_dirs = [
    root / ".agents" / "skills",
    root / ".openhands" / "skills",
    root / ".openhands" / "microagents", # Legacy support
    ]
    _load_and_merge_from_dirs(
    project_skills_dirs, seen_names, all_skills, "project skills"
    )
    logger.debug(
    f"Loaded {len(all_skills)} project skills: {[s.name for s in all_skills]}"
    )
    return all_skills

So when app-server called the SDK agent-server /api/skills endpoint with load_project=True + project_dir, those lines are what loaded the project skills. The missing part in agent-canvas was not this loader; it was taking that loaded catalog and putting it onto the conversation's agent_context.skills (or now, with this PR, setting load_project_skills: true so LocalConversation does it).

This reply was generated by an AI agent (OpenHands) on behalf of the user.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VascoSch92 WDYT, it seems maybe we could avoid to create a redundant loading function?

Copy link
Copy Markdown
Member Author

@VascoSch92 VascoSch92 May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @enyst

Two pieces are involved here:

  • the loader (load_available_skillsload_project_skills), and
  • the merge step that folds loaded skills into agent_context.skills.

The loader is already the single shared entry point: both the AgentContext auto-load path and this PR go through load_available_skills, so that part isn't duplicated. What was duplicated is the small "merge by name" step, hand-rolled in two spots: AgentContext._load_auto_skills (user/public) and LocalConversation._ensure_plugins_loaded (project).

I pulled that into a shared merge_skills_by_name(primary, secondary) in openhands.sdk.skills and routed both through it:

  • _load_auto_skills: merge_skills_by_name(self.skills, auto_skills.values()), explicit skills stay authoritative.
  • _ensure_plugins_loaded: merge_skills_by_name(project_skills.values(), merged_context.skills), project skills stay authoritative.

I kept the orchestration split on purpose, though: user/public resolve eagerly at AgentContext validation, but project skills can't because work_dir isn't known then, and the workspace LocalConversation actually runs in is the resolved worktree path (the agent-canvas#707 concern). That's also why I avoided adding a work_dir/project_dir field to AgentContext: it'd be set before the real workspace is known. So the two sites resolve different sources at the only times each can, now sharing one merge primitive instead of two copies. Precedence stays explicit: public < user < explicit < project.

Happy to reshape if you'd rather it land differently.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM!

# here, where the path is known. Project skills take precedence over
# same-named skills already on the context.
project_skills_loaded = False
if merged_context is not None and merged_context.load_project_skills:
# Best-effort: a failure to load project skills must not prevent the
# conversation from starting. (load_available_skills already guards
# the project source internally; this is belt-and-suspenders.)
try:
project_skills = load_available_skills(
work_dir=self.workspace.working_dir,
include_user=False,
include_project=True,
include_public=False,
)
except Exception:
logger.warning(
"Failed to load project skills; continuing without them",
exc_info=True,
)
project_skills = {}
if project_skills:
Comment thread
VascoSch92 marked this conversation as resolved.
# Project skills are authoritative over same-named context skills.
merged_skills = merge_skills_by_name(
project_skills.values(), merged_context.skills
)
merged_context = merged_context.model_copy(
update={"skills": merged_skills}
)
project_skills_loaded = True

# Expand MCP config variables with per-conversation secrets
# This handles ${VAR} and ${VAR:-default} placeholders:
# - Variables referencing secrets injected via API are expanded to secret values
Expand All @@ -521,9 +554,9 @@ def _ensure_plugins_loaded(self) -> None:
)
logger.debug("Expanded MCP config variables")

# Update agent with merged content only if we have plugins or MCP config
# Skip update when nothing changed to avoid unnecessary agent state mutations
if self._plugin_specs or has_mcp_config:
# Update agent with merged content only if something changed.
# Skip update otherwise to avoid unnecessary agent state mutations.
if self._plugin_specs or has_mcp_config or project_skills_loaded:
self.agent = self.agent.model_copy(
update={
"agent_context": merged_context,
Expand Down
3 changes: 3 additions & 0 deletions openhands-sdk/openhands/sdk/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- `load_user_skills` - Load skills from ~/.openhands/skills/
- `load_public_skills` - Load skills from the public OpenHands extensions repo
- `load_available_skills` - Load and merge skills from multiple sources
- `merge_skills_by_name` - Merge two skill collections by name (primary wins)

**Triggers:**
- `BaseTrigger`, `KeywordTrigger`, `TaskTrigger` - Skill activation triggers
Expand Down Expand Up @@ -63,6 +64,7 @@
load_public_skills,
load_skills_from_dir,
load_user_skills,
merge_skills_by_name,
to_prompt,
)

Expand Down Expand Up @@ -117,6 +119,7 @@
"load_user_skills",
"load_public_skills",
"load_available_skills",
"merge_skills_by_name",
"to_prompt",
# Triggers
"BaseTrigger",
Expand Down
19 changes: 19 additions & 0 deletions openhands-sdk/openhands/sdk/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re
import threading
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Annotated, ClassVar, Literal, Union
from xml.sax.saxutils import escape as xml_escape
Expand Down Expand Up @@ -1203,6 +1204,24 @@ def load_available_skills(
return available


def merge_skills_by_name(
primary: Iterable[Skill], secondary: Iterable[Skill]
) -> list[Skill]:
"""Merge two skill collections by name.

``primary`` skills are authoritative: they take precedence on name conflicts
and keep their order. Each ``secondary`` skill is appended only when its name
is not already provided by ``primary``.
"""
merged = list(primary)
seen = {skill.name for skill in merged}
for skill in secondary:
if skill.name not in seen:
seen.add(skill.name)
merged.append(skill)
return merged


def to_prompt(skills: list[Skill], max_description_length: int = 1024) -> str:
"""Generate XML prompt block for available skills.

Expand Down
136 changes: 135 additions & 1 deletion tests/sdk/conversation/test_repo_root_project_skills.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
from __future__ import annotations

from pathlib import Path
from unittest.mock import patch

from openhands.sdk.agent import Agent
from openhands.sdk.context.agent_context import AgentContext
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.skills import load_project_skills
from openhands.sdk.skills import Skill, load_project_skills
from openhands.sdk.testing import TestLLM


def _agent(agent_context: AgentContext) -> Agent:
return Agent(
llm=TestLLM.from_messages(
[Message(role="assistant", content=[TextContent(text="ok")])],
model="test-model",
),
tools=[],
include_default_tools=[],
agent_context=agent_context,
)


def test_system_prompt_includes_repo_root_agents_md_when_workdir_is_subdir(
tmp_path: Path,
):
Expand Down Expand Up @@ -69,3 +82,124 @@ def test_system_prompt_includes_repo_root_agents_md_when_workdir_is_subdir(
assert "SENTINEL_ROOT_123" in system_prompt_event.dynamic_context.text

conversation.close()


def test_load_project_skills_flag_injects_skills_in_standalone_sdk(tmp_path: Path):
"""``AgentContext(load_project_skills=True)`` works without agent-server.

LocalConversation resolves project skills from the workspace at startup,
so the flag behaves consistently for standalone SDK usage (agent-canvas#574).
"""
(tmp_path / "AGENTS.md").write_text("# Guidelines\n\nSENTINEL_FLAG_456\n")

agent = _agent(
AgentContext(
load_project_skills=True,
current_datetime="2026-01-01T00:00:00Z",
)
)
conversation = LocalConversation(
agent=agent,
workspace=tmp_path,
persistence_dir=tmp_path / "conversation",
delete_on_close=True,
)
conversation.send_message("hi")

# Skills are merged into the live agent context...
assert conversation.agent.agent_context is not None
assert "agents" in {s.name for s in conversation.agent.agent_context.skills}
# ...and rendered into the system prompt.
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_FLAG_456" in system_prompt_event.dynamic_context.text

conversation.close()


def test_load_project_skills_flag_off_does_not_inject(tmp_path: Path):
"""With the flag unset (default), project skills are not loaded."""
(tmp_path / "AGENTS.md").write_text("# Guidelines\n\nSENTINEL_OFF_789\n")

agent = _agent(AgentContext(current_datetime="2026-01-01T00:00:00Z"))
conversation = LocalConversation(
agent=agent,
workspace=tmp_path,
persistence_dir=tmp_path / "conversation",
delete_on_close=True,
)
conversation.send_message("hi")

assert conversation.agent.agent_context is not None
assert conversation.agent.agent_context.skills == []
system_prompt_event = next(
e for e in conversation.state.events if isinstance(e, SystemPromptEvent)
)
dynamic = system_prompt_event.dynamic_context
assert dynamic is None or "SENTINEL_OFF_789" not in dynamic.text

conversation.close()


def test_load_project_skills_flag_merges_with_project_precedence(tmp_path: Path):
"""Project skills override same-named context skills; others are preserved."""
(tmp_path / "AGENTS.md").write_text("# Guidelines\n\nSENTINEL_NEW\n")

agent = _agent(
AgentContext(
skills=[
Skill(name="keep", content="keep me"),
Skill(name="agents", content="OLD_CONTENT"),
],
load_project_skills=True,
current_datetime="2026-01-01T00:00:00Z",
)
)
conversation = LocalConversation(
agent=agent,
workspace=tmp_path,
persistence_dir=tmp_path / "conversation",
delete_on_close=True,
)
conversation.send_message("hi")

assert conversation.agent.agent_context is not None
skills = {s.name: s for s in conversation.agent.agent_context.skills}
assert skills["keep"].content == "keep me"
assert "SENTINEL_NEW" in skills["agents"].content
assert "OLD_CONTENT" not in skills["agents"].content

conversation.close()


def test_load_project_skills_failure_does_not_block_conversation(tmp_path: Path):
"""Project-skill loading is best-effort: a load error must not break startup."""
(tmp_path / "AGENTS.md").write_text("# Guidelines\n\nSENTINEL\n")

agent = _agent(
AgentContext(
skills=[Skill(name="keep", content="keep me")],
load_project_skills=True,
current_datetime="2026-01-01T00:00:00Z",
)
)
conversation = LocalConversation(
agent=agent,
workspace=tmp_path,
persistence_dir=tmp_path / "conversation",
delete_on_close=True,
)

with patch(
"openhands.sdk.conversation.impl.local_conversation.load_available_skills",
side_effect=PermissionError("workspace unreadable"),
):
conversation.send_message("hi") # must not raise

# Conversation started; pre-existing skills are untouched.
assert conversation.agent.agent_context is not None
assert {s.name for s in conversation.agent.agent_context.skills} == {"keep"}

conversation.close()
Loading