OpenHands 源码解析系列
第 8 讲:Skill 系统 —— 从 .md 到 Agent 能力的管道
基于 OpenHands 源码 · 2026-07-29
一、Skill 是什么?
在 OpenHands 中,Skill 是一段以 Markdown 文件为载体、带 YAML frontmatter 的结构化知识,Agent 在运行时会加载这些文件作为"微代理"(microagent)指令。本质上它把"怎么做"从代码中抽离到可配置的文档里。
一个 Skill 文件长什么样?以全局 SSH Skill 为例:
📄 skills/ssh.md(前 20 行)
---
name: SSH Microagent
type: knowledge
version: 1.0.0
agent: CodeActAgent
triggers:
- ssh
- remote server
- remote machine
---
# SSH Microagent
This microagent provides capabilities for
establishing and managing SSH connections...
核心字段:
| 字段 | 说明 | 类型 |
|---|---|---|
| name | Skill 名称 | str |
| type | knowledge / repo / task | str |
| triggers | 触发关键词列表 | list[str] |
| agent | 适用 Agent 类型 | str |
| version | 版本号 | str |
二、Skill 的四个来源
OpenHands 从四个层级加载 Skill,每个层级对应不同的作用域:
Skill 加载层级
🔵 Global(全局)
skills/ 目录,OpenHands 内置的 26 个 Skill
🟢 User(用户级)
~/.openhands/microagents/ 目录,用户自定义
🟡 Project(项目级)
工作区内的 .openhands/ 或 .agents/ 目录
🟠 Org / Marketplace(组织/市场)
Git 仓库中的 .openhands / openhands-config 仓库
三、核心架构:两层分离
OpenHands Skill 系统的关键设计决策是app-server 只做代理,agent-server 做实际加载。源码中的文档字符串说得很明确:
📄 skill_loader.py(第 1-7 行)
"""Utilities for loading skills for V1 conversations.
This module provides functions to load skills from the agent-server,
which centralizes all skill loading logic. The app-server acts as a
thin proxy that:
1. Builds the org_config with authentication information
2. Builds the sandbox_config with exposed URLs
3. Calls the agent-server's /api/skills endpoint
All source-specific skill loading is handled by the agent-server."""
这意味着:
🔹 app-server:构建 org_config(认证信息)、sandbox_config(暴露的 URL),然后发 HTTP 请求到 agent-server 的 /api/skills 端点
🔹 agent-server:真正执行 git clone、解析 frontmatter、合并所有来源的 Skill
四、Skill 加载的完整链路
4.1 入口:load_skills_from_agent_server
这是整个 Skill 加载链路的入口函数(第 618-706 行)。它接收 6 个布尔开关控制哪些来源的 Skill 需要加载:
📄 skill_loader.py(第 618-640 行)
async def load_skills_from_agent_server(
agent_server_url: str,
session_api_key: str | None,
project_dir: str,
org_configs: list[OrgConfig] | None = None,
sandbox_config: SandboxConfig | None = None,
load_public: bool = True,
load_user: bool = True,
load_project: bool = True,
load_org: bool = True,
registered_marketplaces: list[MarketplaceRegistration] | None = None,
) -> list[Skill]:
"""Load all skills from the agent-server."""
payload = {
'load_public': load_public,
'load_user': load_user,
'load_project': load_project,
'load_org': load_org,
'project_dir': project_dir,
'org_configs': [c.model_dump() for c in org_configs] if org_configs else None,
'org_config': org_configs[0].model_dump() if org_configs else None,
'sandbox_config': sandbox_config.model_dump() if sandbox_config else None,
}
注意 org_config 和 org_configs 同时存在 —— 前者是单对象兼容老版本 agent-server,后者是新版的列表形式。这是典型的向后兼容设计。
4.2 构建 OrgConfig:多 Provider 支持
build_org_configs() 函数(第 290-376 行)负责为当前用户的所有 Provider(GitHub/GitLab/Azure DevOps/Bitbucket)找出可访问的 Skill 仓库:
📄 skill_loader.py(第 196-216 行)
def _candidate_repo_paths(provider: ProviderType, owner: str) -> list[str]:
"""Return the global skill-repo paths for an owner."""
if provider == ProviderType.GITLAB:
return [f'{owner}/openhands-config']
if provider == ProviderType.AZURE_DEVOPS:
return [f'{owner}/openhands-config/openhands-config']
return [f'{owner}/.openhands', f'{owner}/.agents']
不同 Provider 的 Skill 仓库命名约定不同:GitHub 用 .openhands 和 .agents,GitLab 用 openhands-config,Azure DevOps 路径更深。这个函数统一了差异。
4.3 并发 URL 解析
候选仓库数可能很多(用户 + 组织),所以用了信号量限制并发:
📄 skill_loader.py(第 220-223 行)
# Upper bound on how many global skill repos we will verify
_MAX_ORG_CANDIDATES = 30
_URL_RESOLVE_CONCURRENCY = 8
最多验证 30 个候选仓库,8 个并发 HTTP 请求。这防止了大规模组织下出现 HTTP 风暴。
4.4 Marketplace 认证
authenticate_marketplace_sources()(第 503-616 行)把 marketplace 的 source 字符串替换为带认证的 Git URL,这样 agent-server 才能 clone 私有仓库:
📄 skill_loader.py(第 503-520 行)
async def authenticate_marketplace_sources(
registered_marketplaces: list[MarketplaceRegistration] | None,
user_context: UserContext,
) -> list[MarketplaceRegistration] | None:
"""Swap auto-load marketplace sources for authenticated git URLs.
The agent-server clones auto_load marketplace registrations itself
but has no provider credentials, so private repositories fail to
clone (and a bare owner/repo source is misread as a nonexistent
local path). Resolve each such source to an authenticated URL via
the user's provider tokens and return copies with source replaced."""
关键点:重写后的 URL 包含凭证,注释明确警告 "never log or persist them"。
五、前端接口:skills_router
skills_router.py 暴露了两个 API 端点供前端使用:
5.1 GET /skills/search —— 搜索 Skill
📄 skills_router.py(第 135-183 行)
GLOBAL_SKILLS_DIR = Path(openhands.__file__).parent.parent / 'skills'
USER_SKILLS_DIR = Path.home() / '.openhands' / 'microagents'
@router.get('/search', response_model=SkillPage)
async def search_skills(
page_id: str | None = None,
limit: int = 100,
) -> SkillPage:
"""Search / list available global and user-level skills."""
skills = []
skills.extend(_load_skills_from_dir(GLOBAL_SKILLS_DIR, 'global'))
skills.extend(_load_skills_from_dir(USER_SKILLS_DIR, 'user'))
skills.sort(key=lambda s: (s.source, s.name))
# cursor-based pagination
page = skills[start : start + limit]
next_page_id = page[-1].name if len(page) == limit else None
return SkillPage(items=page, next_page_id=next_page_id)
注意两个固定路径:
🔹 GLOBAL_SKILLS_DIR → openhands/skills/(内置 26 个)
🔹 USER_SKILLS_DIR → ~/.openhands/microagents/(用户自定义)
5.2 POST /skills/marketplace-skills —— Marketplace 预览
这个端点会实时 git clone marketplace 仓库,解析里面的 Skill 元数据,返回给前端预览。clone 目录用完就删:
📄 skills_router.py(第 200-280 行)
async def _clone_marketplace_repo(
marketplace: MarketplaceRegistration,
user_context: UserContext,
) -> tuple[Path | None, str]:
# ... clone logic with security checks ...
# Run git without a shell (argv form) and use -- so a source/ref
# that begins with '-' can never be parsed as a git option
# (argument injection). Reject leading-'-' values outright.
if clone_url.startswith('-'):
_cleanup_clone_dir(clone_dir)
return None, f'Invalid clone URL: {clone_url}'
result = subprocess.run(
['git', 'clone', '--', clone_url, str(clone_dir)],
capture_output=True, text=True, timeout=120,
)
安全细节:用 subprocess.run 的 argv 形式(非 shell),-- 防止参数注入,startswith('-') 额外防御。120 秒超时。
六、触发机制:TaskTrigger vs KeywordTrigger
Skill 的触发分两种模式,由 _convert_skill_info_to_skill() 决定:
📄 skill_loader.py(第 708-734 行)
def _convert_skill_info_to_skill(skill_info: SkillInfo) -> Skill:
trigger: TaskTrigger | KeywordTrigger | None = None
if skill_info.triggers:
if any(t.startswith('/') for t in skill_info.triggers):
trigger = TaskTrigger(triggers=skill_info.triggers)
else:
trigger = KeywordTrigger(keywords=skill_info.triggers)
return Skill(
name=skill_info.name,
content=skill_info.content,
trigger=trigger,
source=skill_info.source,
description=skill_info.description,
is_agentskills_format=skill_info.is_agentskills_format,
)
判断逻辑很简单:如果 trigger 以 / 开头就是 TaskTrigger(任务路径匹配),否则是 KeywordTrigger(关键词匹配)。前者适合 "当用户做 X 任务时触发",后者适合 "当用户提到 X 关键词时触发"。
Skill 加载完整流程图
用户创建会话 → app-server 构建 org_config + sandbox_config
↓
build_org_configs() 枚举所有 Provider 的候选仓库
↓
并发验证 URL(Semaphore(8),最多 30 个)
↓
authenticate_marketplace_sources() 替换私有仓库 URL
↓
POST /api/skills → agent-server 实际 clone + 解析 + 合并
↓
返回 list[Skill] 给当前会话使用
七、内置 Skill 一览
OpenHands 自带 26 个全局 Skill,覆盖开发、部署、安全等常见场景:
📄 skills/ 目录内容
add_agent.md azure_devops.md github.md
add_repo_inst.md bitbucket_data_center.md gitlab.md
address_pr_comments.md bitbucket.md kubernetes.md
agent-builder.md code-review.md npm.md
agent_memory.md codereview-roasted.md onboarding.md
default-tools.md docker.md pdflatex.md
fix-py-line-too-long.md fix_test.md security.md
flarglebargle.md flarglebargle.md ssh.md
swift-linux.md update_pr_description.md update_test.md
每个 Skill 都是一个独立的 .md 文件,frontmatter 定义触发条件,正文是具体的操作指南。Agent 在会话启动时加载这些内容作为上下文。
八、总结
OpenHands 的 Skill 系统是一个多层级、多 Provider、异步加载的可扩展能力框架。核心设计要点:
🔹 两层分离:app-server 做认证和配置,agent-server 做实际加载
🔹 四级来源:Global → User → Project → Org/Marketplace,层层叠加
🔹 并发控制:Semaphore 限制并发数,_MAX_ORG_CANDIDATES 限制总量
🔹 安全优先:argv 形式调用 git,参数注入防护,URL 认证隔离
🔹 两种触发:TaskTrigger(路径)+ KeywordTrigger(关键词)
下一讲我们将深入 用户认证与权限管理,看看 OpenHands 如何管理用户身份和访问控制。
📚 系列导航
← 第 7 讲:Agent 类型与 ACP 协议
→ 第 9 讲:用户认证与权限管理
关注公众号「AI技术推荐官」获取更多源码解析内容
夜雨聆风