Skip to content
12 changes: 12 additions & 0 deletions openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ 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."
),
json_schema_extra={"acp_compatible": True},
)
secrets: Mapping[str, SecretValue] | None = Field(
default=None,
description=(
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
from openhands.sdk.skills.utils import expand_mcp_variables
from openhands.sdk.subagent import (
AgentDefinition,
Expand Down Expand Up @@ -504,6 +505,36 @@ 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
Member

Choose a reason for hiding this comment

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

I cloned/checked both OpenHands/agent-canvas and OpenHands/openhands.

Short version: app-server worked because it has an app-server-specific injection step; agent-canvas local mode only called /api/skills for the catalog/UI, and that call does not mutate the conversation agent.

Details:

  • In openhands/openhands, v1 conversation start calls _load_skills_onto_request(...) from _build_start_conversation_request_for_user(...). That goes through AppConversationServiceBase.load_and_merge_all_skills(...), POSTs to the SDK agent-server /api/skills with load_project=True and project_dir=get_project_dir(...), then _create_agent_with_skills(...) writes the returned Skills into agent.agent_context.skills before the StartConversationRequest is sent. The /{conversation_id}/skills endpoint also uses the same loader for the UI.
  • In agent-canvas, SkillsService.getSkills() already calls /api/skills with load_project: true and project_dir, so repo skills show on the Skills settings page. But createAgentFromSettings() only seeded agent_context with load_public_skills: true and load_user_skills: true; the /api/skills catalog response was not connected to conversation start. Since AgentContext._load_auto_skills() has no workspace and uses include_project=False, those project skills never reached the system prompt. That is exactly why agent-canvas#707 pre-loaded project skills client-side and injected them as explicit agent_context.skills.

So yes, /api/skills can load project skills, but it is only a loader/catalog API unless the caller explicitly puts its result onto the agent context. App-server had that bridge; agent-canvas local mode did not.

This PR moves the project-skill bridge into SDK LocalConversation, where the conversation workspace is known. That keeps standalone SDK and SDK agent-server behavior consistent and means agent-canvas should only need to send load_project_skills: true after this lands. Org/sandbox sources remain outside this new flag; app-server can keep its broader all-sources injection path for those.

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

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?

# 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:
merged_skills = {s.name: s for s in merged_context.skills}
merged_skills.update(project_skills)
merged_context = merged_context.model_copy(
update={"skills": list(merged_skills.values())}
)
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 +552,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
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