T14 — Markdown-based skill system + frontmatter discovery
Tổng quan Overview
Skill là unit nhỏ nhất của "reusable behavior" trong agent harness — một tập hướng dẫn có tên, mô tả, và nội dung, mà agent có thể invoke bằng slash command. OpenHarness implement skill theo định dạng SKILL.md: YAML frontmatter xác định metadata, markdown body chứa nội dung thực thi.
~/.claude/skills/ và .claude/skills/. OpenHarness mở rộng thêm
plugin skills — skills được bundle trong plugin manifest, giúp
distribute một tập skills cùng với hooks và MCP config như một đơn vị duy nhất.
Phân tích code: skills/loader.py Anatomy
Bước 1: Scan 3 nguồn theo thứ tự ưu tiên
skills/loader.py:27-51 — discovery logic
{`
def load_all_skills(plugin_dirs: list[Path] | None = None) -> list[Skill]:
skills: list[Skill] = []
seen_names: set[str] = set()
# 1. Bundled skills (ship cùng với package)
bundled = Path(__file__).parent / "bundled"
skills.extend(_load_from_dir(bundled, seen_names))
# 2. User skills (~/.openharness/skills/<skill>/SKILL.md)
user_dir = Path.home() / ".openharness" / "skills"
if user_dir.exists():
skills.extend(_load_from_dir(user_dir, seen_names))
# 3. Plugin-contributed skills (từ plugin manifest)
for plugin_dir in (plugin_dirs or []):
skills_path = plugin_dir / "skills"
if skills_path.exists():
skills.extend(_load_from_dir(skills_path, seen_names))
return skills
`}
Thứ tự bundled → user → plugin cho phép plugin override user skill,
và user override bundled skill bằng cách đặt cùng tên. seen_names
dedup để tránh inject 2 skill cùng tên vào system prompt.
Bước 2: Parse frontmatter + fallback
skills/loader.py — _parse_skill_file()
{`
def _parse_skill_file(path: Path) -> Skill | None:
text = path.read_text(encoding="utf-8")
# Try YAML frontmatter (--- ... ---)
if text.startswith("---"):
end = text.index("---", 3)
fm = yaml.safe_load(text[3:end])
body = text[end + 3:].strip()
return Skill(
name=fm.get("name") or path.parent.name,
description=fm.get("description", ""),
body=body,
source=path,
)
# Fallback: heading + first paragraph
lines = text.splitlines()
name = lines[0].lstrip("# ").strip() if lines else path.parent.name
desc = next((l for l in lines[1:] if l.strip()), "")
return Skill(name=name, description=desc, body=text, source=path)
`}Fallback về heading + first paragraph đảm bảo skill vẫn load được kể cả khi author quên viết frontmatter — tăng tolerance cho user-contributed skills.
Bước 3: Inject vào system prompt
prompts/context.py — skills section
{`
def _build_skills_section(skills: list[Skill], permitted: set[str]) -> str:
visible = [s for s in skills if s.name in permitted]
if not visible:
return ""
lines = ["## Available Skills", ""]
for s in visible:
lines.append(f"- **{s.name}**: {s.description}")
lines.append("")
lines.append("Invoke a skill with /skill-name or ask me to use it by name.")
return "\n".join(lines)
`}Chỉ inject name + description vào system prompt (không phải body). Body được load on-demand khi agent invoke skill — tiết kiệm token theo pattern progressive disclosure.
Three-stage loading pattern Pattern
Theo Anthropic Agent Skills docs, skill loading tốt nhất tuân theo 3 stage:
Đây là pattern lazy loading theo demand — giữ system prompt ngắn gọn (chỉ skill names) trong khi cho phép skill body rất dài khi cần. Anthropic docs khuyến cáo: "Keep SKILL.md body under 500 lines for optimal performance. If your content exceeds this, split it into separate files using the progressive disclosure patterns."
path.parent.name tự động handle trường hợp này.
Tương tác với các kỹ thuật khác Interaction
Failure modes Failures
Failure 1: Name mismatch → skill không load
Ví dụ: tên trong frontmatter khác directory name
{`
# Directory: ~/.openharness/skills/code-review/SKILL.md
---
name: code_review # BUG: dùng underscore, directory dùng hyphen
description: Review code changes
---
# Kết quả: skill không được load vào seen_names
# Agent không biết skill tồn tại → user invoke /code_review → "skill not found"
# Fix: name phải khớp với directory name
---
name: code-review
description: Review code changes
---
`}Failure 2: Body quá dài → token bloat
Anti-pattern: nhét tất cả vào body
{`
# BAD: SKILL.md body 2000 dòng với inline templates, examples, code snippets
# → Mỗi lần invoke: load 2000 dòng vào context
# → Tốn ~4000+ tokens chỉ cho skill body
# → Ít chỗ cho tool results và conversation
# GOOD: Progressive disclosure
# SKILL.md body: < 200 dòng (what + how summary)
# skills/code-review/
# SKILL.md ← summary + references
# checklist.md ← detailed checklist (load on-demand)
# template.py ← code template (load on-demand)
`}Failure 3: Scan miss plugin skills
Nếu load_all_skills() được gọi trước khi plugin manifest loaded,
plugin skills sẽ bị miss. OpenHarness giải quyết bằng cách
pass plugin_dirs explicitly sau khi plugin load xong.
So sánh với các harness khác Compare
| Harness | Skill format | Discovery | Fallback | Permission gating |
|---|---|---|---|---|
| OpenHarness | SKILL.md + YAML frontmatter | bundled + user dir + plugin dirs | heading + first paragraph | Có — filter by permission mode |
| Claude Code | SKILL.md + YAML frontmatter | ~/.claude/skills/ + .claude/skills/ | Không rõ | Có — permission-gated |
| Aider | Không có skill system | N/A | N/A | N/A |
| Cursor / Composer | .cursorrules + custom instructions | Project root auto-detect | Không có | Không |
OpenHarness là một trong số ít harnesses triển khai đầy đủ 3-stage loading (discovery → instructions → resources) với fallback parsing và permission gating. Phần lớn harness khác chỉ có 1 loại context injection (rules file hoặc custom instructions) không có khái niệm skill-as-named-unit.
Implementation recipe Recipe
Minimal skill loader với 3-stage pattern, viết thuần Python:
skill_loader.py — minimal implementation
{`
from dataclasses import dataclass
from pathlib import Path
import yaml
@dataclass
class Skill:
name: str
description: str
body: str # không inject vào system prompt mặc định
source: Path
def load_skills(dirs: list[Path]) -> list[Skill]:
skills, seen = [], set()
for d in dirs:
for skill_dir in sorted(d.iterdir()):
md = skill_dir / "SKILL.md"
if not md.exists():
continue
s = _parse(md)
if s and s.name not in seen:
skills.append(s)
seen.add(s.name)
return skills
def _parse(path: Path) -> Skill | None:
text = path.read_text()
if text.startswith("---"):
try:
end = text.index("---", 3)
fm = yaml.safe_load(text[3:end])
return Skill(fm["name"], fm.get("description",""), text[end+3:].strip(), path)
except Exception:
pass
lines = [l for l in text.splitlines() if l.strip()]
if not lines:
return None
name = lines[0].lstrip("# ").strip() or path.parent.name
desc = lines[1].strip() if len(lines) > 1 else ""
return Skill(name, desc, text, path)
# Stage 1: discovery — chỉ inject name + description
def skills_to_system_prompt(skills: list[Skill]) -> str:
if not skills:
return ""
return "## Skills\n" + "\n".join(f"- **{s.name}**: {s.description}" for s in skills)
# Stage 2: load body khi invoke
def load_skill_body(skill: Skill) -> str:
return skill.body # đã parse sẵn, chỉ cần dùng
`}Tham khảo Refs
- Anthropic — Skill authoring best practices · Frontmatter validation rules, 500-line body limit, progressive disclosure pattern
- Skywork.ai — What Is SKILL.md: Structure, Resources & Loading · Giải thích 3-stage loading chi tiết
- Medium — Deep Dive SKILL.md (Part 1/2) · Anatomy từng phần của SKILL.md file
- Medium — Claude Code Extensions Explained · Punchline "Skills like macros" + so sánh skills vs plugins
- OpenCode — Agent Skills docs · opencode implementation tham chiếu
- HumanLayer — Harness Engineering for Coding Agents · Framing harness engineering bao gồm skills/hooks