Skip to content

Commit a5f9bf8

Browse files
qazbnm456claude
andcommitted
feat(skills): auto-injectable skill manifest + discovery="inject" mode
Progressive-disclosure discovery (Anthropic Agent-Skills style): instead of the model spending a tool turn on list_skills to learn the catalog, the caller can inject the catalog into the system prompt once at startup. - render_skills_manifest(skill_dir, header=None): one `- name: description` line per skill; "" when empty. The discovery-level metadata to inject. - load_skills_as_tools(skill_dir, discovery="list"|"inject"): "list" (default, backward-compatible) returns [list_skills, read_skill]; "inject" returns [read_skill] only (discovery handled by the injected manifest). read_skill stays as the just-in-time full-body activation path in both modes. Default is unchanged, so existing consumers keep the 2-tuple. Export render_skills_manifest from the package root. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b508c6d commit a5f9bf8

3 files changed

Lines changed: 92 additions & 10 deletions

File tree

rlm_kit/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .dataset import export_actions, export_rl, export_sft_turns
2525
from .replay import RecordedToolProvider, load_timeline, reconstruct
2626
from .sandbox import SandboxSecurityError
27-
from .skills import discover_skills, load_skills_as_tools
27+
from .skills import discover_skills, load_skills_as_tools, render_skills_manifest
2828
from .sub_lm import SubLMValidationError, intercept_sub_lm, model_as_tool
2929
from .trace import (
3030
TraceRecorder,
@@ -46,6 +46,7 @@
4646
"SubLMValidationError",
4747
"model_as_tool",
4848
"load_skills_as_tools",
49+
"render_skills_manifest",
4950
"discover_skills",
5051
# tracing (Phase B)
5152
"TraceRecorder",

rlm_kit/skills.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@
22
33
A "Skill" here follows the common convention of a folder containing a ``SKILL.md``
44
(with optional YAML-ish frontmatter for ``name``/``description``), or a flat
5-
``<name>.md`` file. We surface two tools to the main LM so it can decide, inside
6-
the REPL, which skill to read — keeping control flow in the LM's hands (the run
7-
stays an RLM, and the decision lands in the trajectory).
5+
``<name>.md`` file. The main LM decides, inside the REPL, which skill to read —
6+
keeping control flow in the LM's hands (the run stays an RLM, and the decision
7+
lands in the trajectory).
8+
9+
Two DISCOVERY models, per the ``discovery`` arg of :func:`load_skills_as_tools`
10+
(progressive disclosure, Anthropic Agent-Skills style):
11+
12+
- ``"list"`` — surface a ``list_skills`` tool; the LM calls it to see the catalog
13+
(a discovery round-trip that costs one tool turn). The default; backward-compatible.
14+
- ``"inject"`` — the caller injects the catalog into the system prompt once at
15+
startup via :func:`render_skills_manifest`, so the LM always knows which skills
16+
exist without a tool call; only ``read_skill`` (the just-in-time full-body pull)
17+
is surfaced as a tool.
818
919
Pure stdlib; no dspy import.
1020
"""
@@ -84,12 +94,42 @@ def _safe_read(path: str) -> str:
8494
return ""
8595

8696

87-
def load_skills_as_tools(skill_dir: str) -> list[Callable]:
88-
"""Return two tools — ``list_skills`` and ``read_skill`` — over ``skill_dir``.
97+
def render_skills_manifest(skill_dir: str, *, header: Optional[str] = None) -> str:
98+
"""Render the skill catalog — one ``- name: description`` line per skill — for injection
99+
into a system prompt. This is the DISCOVERY level of progressive disclosure (Anthropic
100+
Agent Skills): surface every skill's metadata ONCE at startup so the model always knows
101+
which skills exist, instead of spending a turn calling ``list_skills`` to find out. Pair it
102+
with ``load_skills_as_tools(skill_dir, discovery="inject")``, which then omits ``list_skills``
103+
and keeps only ``read_skill`` (the just-in-time full-body pull).
104+
105+
Returns ``""`` when the directory holds no skills (nothing to inject). ``header``, when
106+
given, is prepended on its own line (e.g. an ``<available_skills>`` label).
107+
"""
108+
skills = discover_skills(skill_dir)
109+
if not skills:
110+
return ""
111+
body = "\n".join(
112+
f"- {s.name}: {s.description or '(no description)'}" for s in skills
113+
)
114+
return f"{header}\n{body}" if header else body
115+
116+
117+
def load_skills_as_tools(skill_dir: str, *, discovery: str = "list") -> list[Callable]:
118+
"""Return the skill tools over ``skill_dir``.
119+
120+
``discovery`` selects HOW the model learns which skills exist:
121+
122+
- ``"list"`` (default): return ``[list_skills, read_skill]``. The model calls ``list_skills``
123+
to see the catalog — a discovery round-trip that costs one tool turn. Backward-compatible.
124+
- ``"inject"``: return ``[read_skill]`` ONLY. The caller injects the catalog into the system
125+
prompt via :func:`render_skills_manifest` (skill metadata auto-surfaced at startup, no
126+
discovery tool call). ``read_skill`` stays as the just-in-time activation path.
89127
90-
Both record a ``tool_call`` event so skill access shows up in the trajectory
91-
and the RL dataset.
128+
``read_skill`` (and ``list_skills`` when present) records a ``tool_call`` event so skill
129+
access shows up in the trajectory and the RL dataset.
92130
"""
131+
if discovery not in ("list", "inject"):
132+
raise ValueError(f"discovery must be 'list' or 'inject', got {discovery!r}")
93133
skills = {s.name: s for s in discover_skills(skill_dir)}
94134

95135
def list_skills() -> str:
@@ -114,4 +154,4 @@ def read_skill(name: str) -> str:
114154
preview=result[:_PREVIEW_CHARS])
115155
return result
116156

117-
return [list_skills, read_skill]
157+
return [read_skill] if discovery == "inject" else [list_skills, read_skill]

tests/test_skills.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
from rlm_kit.skills import discover_skills, load_skills_as_tools
1+
import pytest
2+
3+
from rlm_kit.skills import (
4+
discover_skills,
5+
load_skills_as_tools,
6+
render_skills_manifest,
7+
)
28
from rlm_kit.trace import EVENT_TOOL_CALL, TraceRecorder, load_events
39

410

@@ -42,3 +48,38 @@ def test_skills_tools_record_events(tmp_path):
4248
read_skill("recon")
4349
tools = [e for e in load_events(path) if e["type"] == EVENT_TOOL_CALL]
4450
assert [t["payload"]["tool"] for t in tools] == ["list_skills", "read_skill"]
51+
52+
53+
def test_render_skills_manifest(tmp_path):
54+
sd = _make_skill_dir(tmp_path)
55+
manifest = render_skills_manifest(sd)
56+
# one `- name: description` line per skill; missing description falls back to a placeholder
57+
assert "- recon: Recon helper" in manifest
58+
assert "- triage: (no description)" in manifest
59+
# header, when given, is prepended on its own line
60+
with_header = render_skills_manifest(sd, header="<available_skills>")
61+
assert with_header.startswith("<available_skills>\n- ")
62+
63+
64+
def test_render_manifest_empty_when_no_skills():
65+
# nothing to inject → empty string (caller can skip the block)
66+
assert render_skills_manifest("/no/such/dir") == ""
67+
68+
69+
def test_load_skills_discovery_inject_omits_list_skills(tmp_path):
70+
# discovery="inject" surfaces ONLY read_skill (the JIT pull); discovery is handled by the
71+
# injected manifest, so there is no list_skills tool.
72+
tools = load_skills_as_tools(_make_skill_dir(tmp_path), discovery="inject")
73+
assert [t.__name__ for t in tools] == ["read_skill"]
74+
assert "recon steps" in tools[0]("recon")
75+
76+
77+
def test_load_skills_discovery_list_is_default_and_backward_compatible(tmp_path):
78+
# default stays the 2-tuple [list_skills, read_skill] so existing callers keep working.
79+
tools = load_skills_as_tools(_make_skill_dir(tmp_path))
80+
assert [t.__name__ for t in tools] == ["list_skills", "read_skill"]
81+
82+
83+
def test_load_skills_discovery_invalid_raises(tmp_path):
84+
with pytest.raises(ValueError, match="discovery must be"):
85+
load_skills_as_tools(_make_skill_dir(tmp_path), discovery="bogus")

0 commit comments

Comments
 (0)