Skip to content

Commit a74c8fe

Browse files
authored
feat: agent skill evaluation infrastructure (#1605)
Implements #1256 (skill evaluation infrastructure), part of epic #1494. ## Summary Adds skills as a **run-level evaluation variable, decoupled from the dataset**. Skills are specified via `skills.path` on `gym eval run` and applied to a fixed, skill-agnostic dataset at rollout time — no dataset-row coupling (deliberately *not* modeled like `agent_ref`). Each rollout result is stamped with a `skills_ref` for provenance and variant comparison. ### Core (agent-agnostic) * `nemo_gym/skills.py` — `load_skill_directory` (parses `SKILL.md` frontmatter, actionable errors for malformed skills), `hash_skill_dir` (short content sha256), `stage_skills`, and `SkillsConfig`/`SkillsRef` models. * `rollout_collection.py` — optional `skills` config; resolve once at startup, stamp `skills_ref` (path + hash + metadata) onto each row, propagate to results. * `skills_ref` **carries a content** `hash` so optimizer loops (e.g. ACE, GEPA, EvoSkill) that mutate a skill *in place* at the same path stay distinguishable in reward profiling. Identity is derived from bytes on disk — no optimizer cooperation required. ### Claude Code agent (thin per-agent adapter) * Reads `skills_ref.path` from the `/run` request, stages it into the per-request `CLAUDE_CONFIG_DIR/skills/`, and forces `--bare` off so native discovery loads it. **No skill-interpretation logic added** — Claude loads/activates natively. ### Docs * `agent-server/agent-skills` reference page: workflow, `skills_ref` hash rationale, and the three-step per-agent adapter contract for future agents (Codex, MCP). ## Design notes The decoupled (run-level, not dataset-row) design and the content-hash variant identity are discussed in [https://github.com/NVIDIA-NeMo/Gym/issues/1256#issuecomment-4715376347](<https://github.com/NVIDIA-NeMo/Gym/issues/1256#issuecomment-4715376347>). A committed teaching example + end-to-end "evaluate and optimize skills" tutorial (with a composite-reward example) is tracked separately in #1604. ## Test plan - [X] Unit tests: `tests/unit_tests/test_skills.py` (incl. spec-compliant `metadata.version`, UTF-8 BOM tolerance), rollout `skills_ref` stamping + `resume_from_cache` round-trip, and agent-side `_build_command` skills/`--bare` handling, per-request config-dir cleanup on staging failure, and `run()` → `_create_response` `skills_path` forwarding. `ruff` clean. - [X] End-to-end smoke test (reproducible steps below). ## Smoke test (reproducible) Validates both halves end to end against the existing reasoning_gym + Claude Code agent setup, using two variants of the *same* skill with different content (so hashes differ) and a distinct output marker per variant (to prove activation). ### 1\. Create two throwaway skill variants ```bash mkdir -p /tmp/ng_skill_smoke/variant_a/logic_puzzle_solver /tmp/ng_skill_smoke/variant_b/logic_puzzle_solver ``` `/tmp/ng_skill_smoke/variant_a/logic_puzzle_solver/SKILL.md`: ```markdown --- name: logic_puzzle_solver description: Use when solving knights-and-knaves logic puzzles where some characters always tell the truth and others always lie. --- # Logic Puzzle Solver When solving a knights-and-knaves puzzle: 1. List each character and the two hypotheses (knight = truth-teller, knave = liar). 2. For each statement, work out what it implies under each hypothesis. 3. Eliminate contradictions until a consistent assignment remains. Always finish your final answer with the marker: [solved-with-skill-v1] ``` `/tmp/ng_skill_smoke/variant_b/logic_puzzle_solver/SKILL.md` — identical except a truth-table approach in the body and the marker `[solved-with-skill-v2]` (the differing content is what makes the hashes differ). ### 2\. Start the servers (with this branch's code) ```bash gym env start "+config_paths=[resources_servers/reasoning_gym/configs/reasoning_gym_claude_code_agent.yaml]" ``` ### 3\. Collect rollouts for each variant over the same dataset ```bash gym eval run --no-serve \ +agent_name=reasoning_gym_claude_code_agent \ +input_jsonl_fpath=resources_servers/reasoning_gym/data/example.jsonl \ +output_jsonl_fpath=/tmp/skills_smoke_a.jsonl \ +skills.path=/tmp/ng_skill_smoke/variant_a \ +limit=1 gym eval run --no-serve \ +agent_name=reasoning_gym_claude_code_agent \ +input_jsonl_fpath=resources_servers/reasoning_gym/data/example.jsonl \ +output_jsonl_fpath=/tmp/skills_smoke_b.jsonl \ +skills.path=/tmp/ng_skill_smoke/variant_b \ +limit=1 ``` ### 4\. Verify ```bash python -c "import json;print(json.loads(open('/tmp/skills_smoke_a.jsonl').readline())['skills_ref'])" python -c "import json;print(json.loads(open('/tmp/skills_smoke_b.jsonl').readline())['skills_ref'])" grep -o 'solved-with-skill-v[12]' /tmp/skills_smoke_*.jsonl ``` ### Observed results <!-- linear:table-colwidths:266,266,266 --> | | variant A | variant B | | -- | -- | -- | | `skills_ref.path` | `/tmp/ng_skill_smoke/variant_a` | `/tmp/ng_skill_smoke/variant_b` | | `skills_ref.hash` | `4cff9c793395` | `100c63f5ea97` | | `skills_ref.skills` | `logic_puzzle_solver` (name + description parsed from frontmatter) | same name/description, different content | | output marker | `solved-with-skill-v1` | `solved-with-skill-v2` | Confirms: * **Gym side:** `skills_ref` stamped + propagated to results, with **distinct content hashes** for same-named variants. * **Agent side:** each variant was staged into its own per-request `CLAUDE_CONFIG_DIR/skills/`, `--bare` was dropped, and Claude **loaded and followed the correct variant with no cross-contamination** (A→v1, B→v2). --------- Signed-off-by: Chris Wing <cwing@nvidia.com>
1 parent 9284c5b commit a74c8fe

9 files changed

Lines changed: 885 additions & 15 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
title: "Agent Skills"
3+
description: "Evaluate agent skills as a run-level variable, decoupled from the dataset"
4+
position: 2
5+
---
6+
7+
# Agent Skills
8+
9+
Skills are reusable units of operational knowledge an agent can load at runtime, following the open [Agent Skills standard](https://agentskills.io/specification) used by Claude Code and Codex CLI. A skill is a **directory** containing a `SKILL.md` file (YAML frontmatter + markdown body) plus optional supporting files.
10+
11+
NeMo Gym treats skills as an **environment-level variable you can sweep**, the same way it treats prompts. You point a run at a directory of skills, the agent loads them with its own native mechanism, and each rollout result is tagged so you can compare variants. NeMo Gym does not interpret or activate skills — it sets up the skill environment and measures the outcome.
12+
13+
## Skills are a run-level knob, not a dataset field
14+
15+
Skills are specified on `gym eval run` and applied to a **fixed, skill-agnostic dataset** at rollout time. The dataset stays a description of tasks, so the same dataset is reusable across skill variants and across agents.
16+
17+
## Usage
18+
19+
A single `skills.path` field points at a directory of skill directories (here `--no-serve` collects against servers already started with `gym env start`):
20+
21+
```bash
22+
gym eval run --no-serve +agent_name=my_agent \
23+
+input_jsonl_fpath=data/tasks.jsonl \
24+
+output_jsonl_fpath=results/rollouts_variant_a.jsonl \
25+
+skills.path=skills/variant_a/
26+
```
27+
28+
The directory follows the standard layout — one subdirectory per skill, each with a `SKILL.md`:
29+
30+
```
31+
skills/variant_a/
32+
├── cot_enhanced/
33+
│ └── SKILL.md
34+
├── tool_focused/
35+
│ ├── SKILL.md
36+
│ └── references/
37+
│ └── api_spec.md
38+
└── baseline/
39+
└── SKILL.md
40+
```
41+
42+
To compare variants, run again with a different `skills.path` over the same dataset. The skills path is resolved like `input_jsonl_fpath` (relative paths check the working directory, then the Gym root). For distributed runs the directory must be on storage accessible to the agent process.
43+
44+
## Output: `skills_ref`
45+
46+
Each rollout result is stamped with a `skills_ref` for provenance and grouping during reward profiling:
47+
48+
```json
49+
{
50+
"reward": 1.0,
51+
"skills_ref": {
52+
"path": "skills/variant_a/",
53+
"hash": "a1b2c3…",
54+
"skills": [{"name": "cot_enhanced", "description": "..."}]
55+
}
56+
}
57+
```
58+
59+
`hash` is a short content digest of the skill directory. It exists so that optimizer loops (e.g. ACE, GEPA, EvoSkill) that mutate a skill **in place** at the same path still produce distinguishable variants — identity is derived from bytes on disk, requiring no cooperation from the optimizer. Identical content yields an identical hash, so re-testing a prior variant automatically pools with its earlier evaluation.
60+
61+
<Tip>
62+
For concurrent candidate evaluation (population search), write each candidate to its own directory (`skills/cand-0/`, `skills/cand-1/`, …) to avoid a path-reuse read/write race.
63+
</Tip>
64+
65+
## How skills reach the agent
66+
67+
NeMo Gym handles everything agent-agnostic: it resolves `skills.path`, computes the `skills_ref`, stamps it onto each request, and propagates it to results. The agent's job is only to make the resolved directory discoverable by its native runtime — because *where* a runtime discovers skills is intrinsically agent-specific.
68+
69+
This splits cleanly into **shared core** and a **thin per-agent adapter**:
70+
71+
| Responsibility | Owner |
72+
|---|---|
73+
| `skills.path` config, load/validate, content hash, `skills_ref` stamping + propagation | NeMo Gym core (shared) |
74+
| Read `skills_ref.path` from the request | Per-agent (identical shape) |
75+
| Stage the directory into the runtime's discovery location | Per-agent (location differs) |
76+
| Enable native discovery; load/activate skills | The agent's native runtime |
77+
78+
## Adding skills support to an agent
79+
80+
To support skills in a new agent, implement the three-step adapter. NeMo Gym provides the shared utilities in `nemo_gym.skills`.
81+
82+
1. **Read the skills path from the request.** NeMo Gym stamps `skills_ref` (with `path`) onto the `/run` request body. Read `skills_ref["path"]` in your agent's `run()`.
83+
84+
2. **Stage the directory where your runtime discovers skills**, using `nemo_gym.skills.stage_skills(path, dest_dir)`. The destination is agent-specific:
85+
86+
| Agent | Discovery location |
87+
|---|---|
88+
| Claude Code | `CLAUDE_CONFIG_DIR/skills/` |
89+
| Codex CLI | the Codex config home (e.g. `$CODEX_HOME`) |
90+
| MCP-backed agent | configure the MCP skill server to serve from the directory |
91+
92+
Prefer staging into a **per-request** ephemeral location so concurrent requests with different skills do not contaminate one another, and so nothing leaks between rollouts.
93+
94+
3. **Enable native discovery.** If your runtime disables discovery by default (Claude Code runs `--bare`), turn it on when skills are present. Then the agent's native loader handles selection and activation — NeMo Gym adds no skill-interpretation logic.
95+
96+
Note that enabling discovery is usually all-or-nothing: with Claude Code, dropping `--bare` so skills load also re-enables *all* other auto-discovery — hooks, plugins, MCP servers, memory, and `CLAUDE.md` — not just skills. If you are measuring a skill's isolated impact, be aware that a baseline (`--bare`, no skills) versus a skills run may differ by more than the skill content alone.
97+
98+
<Note>
99+
The reference implementation is the [Claude Code agent](https://github.com/NVIDIA-NeMo/Gym/tree/main/responses_api_agents/claude_code_agent). It stages skills into a fresh per-request `CLAUDE_CONFIG_DIR/skills/` and drops `--bare` when skills are active.
100+
</Note>
101+
102+
## What NeMo Gym does not do
103+
104+
- It does not parse, select, inject, or activate skills — the agent's native runtime does all of that.
105+
- It does not add skill-loading capabilities to agents that lack them. Injecting skill content into the prompt for such agents is a possible future enhancement, not part of this design.

nemo_gym/global_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
RESPONSES_CREATE_PARAMS_KEY_NAME = "responses_create_params"
118118
RESPONSE_KEY_NAME = "response"
119119
AGENT_REF_KEY_NAME = "agent_ref"
120+
SKILLS_REF_KEY_NAME = "skills_ref"
120121

121122
POLICY_BASE_URL_KEY_NAME = "policy_base_url"
122123
POLICY_API_KEY_KEY_NAME = "policy_api_key" # pragma: allowlist secret

nemo_gym/rollout_collection.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
AGENT_REF_KEY_NAME,
3939
RESPONSES_CREATE_PARAMS_KEY_NAME,
4040
ROLLOUT_INDEX_KEY_NAME,
41+
SKILLS_REF_KEY_NAME,
4142
TASK_INDEX_KEY_NAME,
4243
get_wandb_run,
4344
)
@@ -51,6 +52,7 @@
5152
raise_for_status,
5253
set_global_aiohttp_client,
5354
)
55+
from nemo_gym.skills import SkillsConfig, load_skill_directory
5456

5557

5658
# ---------------------------------------------------------------------------
@@ -198,6 +200,10 @@ class RolloutCollectionConfig(SharedRolloutCollectionConfig):
198200
default=None,
199201
description="Path to a prompt YAML file. Builds responses_create_params.input from the template at rollout time. Mutually exclusive with pre-populated responses_create_params.input in the JSONL data.",
200202
)
203+
skills: Optional[SkillsConfig] = Field(
204+
default=None,
205+
description="Run-level skills config (skills.path). Makes a directory of Agent Skills standard skills available to the agent at rollout time and stamps each result with a skills_ref. Applied to a skill-agnostic dataset; not a dataset-row field.",
206+
)
201207

202208
@model_validator(mode="after")
203209
def _validate_num_repeats(self) -> "RolloutCollectionConfig":
@@ -268,6 +274,17 @@ def _preprocess_rows_from_config(self, config: RolloutCollectionConfig) -> List[
268274
prompt_cfg = load_prompt_config(config.prompt_config)
269275
print(f"Using prompt config: {config.prompt_config}")
270276

277+
# Resolve skills once for the whole run (hash is content-derived, computed at startup).
278+
skills_ref_dict = None
279+
if config.skills:
280+
skills_ref = load_skill_directory(config.skills.path)
281+
skills_ref_dict = skills_ref.model_dump()
282+
print(
283+
f"Using skills from {config.skills.path} "
284+
f"(hash={skills_ref.hash}, {len(skills_ref.skills)} skill(s): "
285+
f"{', '.join(s.name for s in skills_ref.skills)})"
286+
)
287+
271288
_input_path = Path(config.input_jsonl_fpath)
272289
if not _input_path.is_absolute():
273290
_cwd_path = Path.cwd() / _input_path
@@ -305,6 +322,11 @@ def _preprocess_rows_from_config(self, config: RolloutCollectionConfig) -> List[
305322
row[RESPONSES_CREATE_PARAMS_KEY_NAME] | responses_create_params_overrides
306323
)
307324

325+
# Stamp the run-level skills_ref onto the row so it is sent to the agent in the
326+
# /run request body and propagated to results. The source dataset stays untouched.
327+
if skills_ref_dict is not None:
328+
row[SKILLS_REF_KEY_NAME] = skills_ref_dict
329+
308330
# Resolve task index. Honor a caller-provided value when present (e.g. when an
309331
# upstream slicer has stamped a globally-stable index across chunks so that
310332
# subsequent /aggregate_metrics groupby unions chunks correctly); otherwise dedupe
@@ -467,6 +489,8 @@ async def run_from_config(self, config: RolloutCollectionConfig) -> Tuple[List[D
467489
result[TASK_INDEX_KEY_NAME] = row[TASK_INDEX_KEY_NAME]
468490
result[ROLLOUT_INDEX_KEY_NAME] = row[ROLLOUT_INDEX_KEY_NAME]
469491
result[AGENT_REF_KEY_NAME] = row[AGENT_REF_KEY_NAME]
492+
if SKILLS_REF_KEY_NAME in row:
493+
result[SKILLS_REF_KEY_NAME] = row[SKILLS_REF_KEY_NAME]
470494

471495
no_persist = bool(result.get(NG_NO_PERSIST_KEY))
472496
failure_class = result.get(NG_FAILURE_CLASS_KEY)

nemo_gym/skills.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Agent skills: a directory of skills made available to an agent at rollout time.
16+
17+
Skills follow the open `Agent Skills standard <https://agentskills.io/specification>`_
18+
used by Codex CLI and Claude Code. A skill is a *directory* containing a ``SKILL.md``
19+
file (YAML frontmatter + markdown body) plus optional supporting files. The ``skills.path``
20+
config points at a directory of such skill directories.
21+
22+
Skills are a run-level knob (specified on ``ng_collect_rollouts``), applied to a fixed,
23+
skill-agnostic dataset -- mirroring how ``prompt.py`` applies a prompt template. They are
24+
*not* a dataset-row field, so the same dataset is reusable across skill variants. Each
25+
rollout result is stamped with a ``skills_ref`` for provenance/grouping in reward profiling.
26+
27+
The ``skills_ref`` carries a content ``hash`` (a short sha256 over the skill directory's
28+
sorted relative paths + file bytes) so that variants that mutate a skill *in place* at the
29+
same path -- as optimizer loops like ACE, GEPA, and EvoSkill commonly do -- remain
30+
distinguishable when comparing rollouts. Identity is derived from bytes on disk, so it
31+
requires no cooperation from the optimizer.
32+
"""
33+
34+
import hashlib
35+
import shutil
36+
from pathlib import Path
37+
from typing import List, Optional
38+
39+
import yaml
40+
from pydantic import BaseModel, Field
41+
42+
from nemo_gym import PARENT_DIR
43+
44+
45+
SKILL_MD_FILENAME = "SKILL.md"
46+
# 12 hex chars (48 bits) is plenty to separate the handful of variants in one experiment
47+
# while staying readable in tables / W&B.
48+
_HASH_PREFIX_LEN = 12
49+
50+
51+
class SkillMetadata(BaseModel):
52+
"""Metadata parsed from a single skill's ``SKILL.md`` YAML frontmatter."""
53+
54+
name: str
55+
description: Optional[str] = None
56+
version: Optional[str] = None
57+
58+
59+
class SkillsRef(BaseModel):
60+
"""Provenance stamp describing the skills made available for a run.
61+
62+
Stamped onto rollout result rows (not source datasets). ``hash`` is a content
63+
digest so two skill *versions* at the same ``path`` do not collide in profiling.
64+
"""
65+
66+
path: str
67+
hash: str
68+
skills: List[SkillMetadata]
69+
70+
71+
class SkillsConfig(BaseModel):
72+
"""Run-level skills config: ``skills.path`` points at a directory of skill directories."""
73+
74+
path: str = Field(description="Directory of Agent Skills standard skill directories to make available.")
75+
76+
77+
def _resolve_skills_path(path: str) -> Path:
78+
"""Resolve a skills path. Relative paths check cwd first, then the Gym root (PARENT_DIR).
79+
80+
This matches how ``input_jsonl_fpath`` and ``config_paths`` are resolved.
81+
"""
82+
p = Path(path)
83+
if p.is_absolute():
84+
return p
85+
cwd_path = Path.cwd() / p
86+
return cwd_path if cwd_path.exists() else PARENT_DIR / p
87+
88+
89+
def hash_skill_dir(root: Path) -> str:
90+
"""Compute a stable short sha256 over a skill directory's contents.
91+
92+
Walks files in sorted relative-path order, folding each file's relative path and bytes
93+
into the digest. Including the relative path means renaming or adding/removing files also
94+
changes the hash -- a skill *is* its file layout, not just ``SKILL.md``'s bytes.
95+
"""
96+
h = hashlib.sha256()
97+
for file_path in sorted(p for p in root.rglob("*") if p.is_file()):
98+
h.update(file_path.relative_to(root).as_posix().encode())
99+
h.update(b"\0")
100+
h.update(file_path.read_bytes())
101+
h.update(b"\0")
102+
return h.hexdigest()[:_HASH_PREFIX_LEN]
103+
104+
105+
def parse_skill_md(skill_md_path: Path) -> SkillMetadata:
106+
"""Parse a ``SKILL.md`` file's YAML frontmatter into ``SkillMetadata``.
107+
108+
Frontmatter is delimited by lines containing only ``---``. Raises ``ValueError`` with a
109+
clear message if the frontmatter is missing, malformed, or lacks a ``name``.
110+
"""
111+
# utf-8-sig transparently strips a leading BOM if present; Claude Code's own loader tolerates
112+
# one, so a BOM should not make Gym reject a skill that would otherwise run.
113+
text = skill_md_path.read_text(encoding="utf-8-sig", errors="replace")
114+
lines = text.splitlines()
115+
if not lines or lines[0].strip() != "---":
116+
raise ValueError(
117+
f"Skill file {skill_md_path} is missing YAML frontmatter. "
118+
f"It must start with a '---' line followed by 'name:' and 'description:' fields."
119+
)
120+
121+
closing_idx = None
122+
for idx in range(1, len(lines)):
123+
if lines[idx].strip() == "---":
124+
closing_idx = idx
125+
break
126+
if closing_idx is None:
127+
raise ValueError(
128+
f"Skill file {skill_md_path} has an unterminated YAML frontmatter block (no closing '---' line)."
129+
)
130+
131+
frontmatter_text = "\n".join(lines[1:closing_idx])
132+
try:
133+
data = yaml.safe_load(frontmatter_text) or {}
134+
except yaml.YAMLError as e:
135+
raise ValueError(f"Skill file {skill_md_path} has malformed YAML frontmatter: {e}") from None
136+
if not isinstance(data, dict):
137+
raise ValueError(f"Skill file {skill_md_path} frontmatter must be a YAML mapping, got {type(data).__name__}.")
138+
if not data.get("name"):
139+
raise ValueError(f"Skill file {skill_md_path} frontmatter is missing a required 'name' field.")
140+
141+
# The Agent Skills spec nests version under an optional `metadata:` map
142+
# (e.g. `metadata: {version: "1.0"}`), not as a top-level key.
143+
metadata = data.get("metadata")
144+
version = str(metadata["version"]) if isinstance(metadata, dict) and metadata.get("version") is not None else None
145+
return SkillMetadata(
146+
name=str(data["name"]),
147+
description=data.get("description"),
148+
version=version,
149+
)
150+
151+
152+
def load_skill_directory(path: str) -> SkillsRef:
153+
"""Load a directory of skills, returning a ``SkillsRef`` (path, content hash, metadata).
154+
155+
The directory at ``path`` contains one subdirectory per skill, each with a ``SKILL.md``.
156+
Raises ``ValueError`` with an actionable message if the path is missing, is not a
157+
directory, contains no skills, or contains a malformed skill.
158+
"""
159+
resolved = _resolve_skills_path(path)
160+
if not resolved.exists():
161+
raise ValueError(f"Skills path does not exist: {resolved} (from skills.path={path!r}).")
162+
if not resolved.is_dir():
163+
raise ValueError(
164+
f"Skills path must be a directory of skill directories, but {resolved} is a file "
165+
f"(from skills.path={path!r})."
166+
)
167+
168+
skill_dirs = sorted(d for d in resolved.iterdir() if d.is_dir())
169+
skills: List[SkillMetadata] = []
170+
for skill_dir in skill_dirs:
171+
skill_md = skill_dir / SKILL_MD_FILENAME
172+
if not skill_md.is_file():
173+
raise ValueError(
174+
f"Skill directory {skill_dir} is missing a {SKILL_MD_FILENAME} file. "
175+
f"Each skill must be a directory containing a {SKILL_MD_FILENAME}."
176+
)
177+
skills.append(parse_skill_md(skill_md))
178+
179+
if not skills:
180+
raise ValueError(
181+
f"Skills path {resolved} contains no skills. Expected one or more subdirectories, "
182+
f"each containing a {SKILL_MD_FILENAME} file (from skills.path={path!r})."
183+
)
184+
185+
return SkillsRef(path=path, hash=hash_skill_dir(resolved), skills=skills)
186+
187+
188+
def stage_skills(path: str, dest_skills_dir: Path) -> None:
189+
"""Copy the directory of skills at ``path`` into ``dest_skills_dir``.
190+
191+
Used by agent runtimes to materialize skills into a location their native discovery
192+
mechanism scans (e.g. ``<CLAUDE_CONFIG_DIR>/skills/`` for Claude Code). ``dest_skills_dir``
193+
must not already exist. Raises ``ValueError`` if the source path is missing or not a directory.
194+
"""
195+
resolved = _resolve_skills_path(path)
196+
if not resolved.is_dir():
197+
raise ValueError(
198+
f"Cannot stage skills: {resolved} is not a directory (from skills path {path!r}). "
199+
f"For distributed runs the skills directory must be on storage accessible to the agent."
200+
)
201+
shutil.copytree(resolved, dest_skills_dir)

0 commit comments

Comments
 (0)