agent-skill-description-optimizer 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_skill_description_optimizer-0.2.0.dist-info/METADATA +361 -0
- agent_skill_description_optimizer-0.2.0.dist-info/RECORD +17 -0
- agent_skill_description_optimizer-0.2.0.dist-info/WHEEL +4 -0
- agent_skill_description_optimizer-0.2.0.dist-info/entry_points.txt +3 -0
- agent_skill_description_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
- skill_optimizer/__init__.py +78 -0
- skill_optimizer/__main__.py +6 -0
- skill_optimizer/_process.py +42 -0
- skill_optimizer/cli.py +1450 -0
- skill_optimizer/evaluation.py +475 -0
- skill_optimizer/improver.py +636 -0
- skill_optimizer/interpreter.py +171 -0
- skill_optimizer/models.py +155 -0
- skill_optimizer/py.typed +0 -0
- skill_optimizer/report.py +297 -0
- skill_optimizer/selection.py +250 -0
- skill_optimizer/skill_md.py +123 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Eval-set splitting, model resolution, and candidate-selection helpers."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
from skill_optimizer.models import MODEL_ALIASES, EvalQuery, EvalResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def stratified_split(
|
|
10
|
+
eval_set: list[EvalQuery], test_frac: float, seed: int = 42
|
|
11
|
+
) -> tuple[list[int], list[int]]:
|
|
12
|
+
"""Split query *indices* into train/test, preserving the trigger/no-trigger ratio.
|
|
13
|
+
|
|
14
|
+
A ``test_frac`` of zero disables the held-out set (skill-creator's ``--holdout 0``
|
|
15
|
+
semantics): every index becomes a training index, in original order, and no class
|
|
16
|
+
balancing is required. A positive fraction must be finite and in ``[0, 1)``, each
|
|
17
|
+
trigger class must hold at least two queries, and the seeded per-class allocation
|
|
18
|
+
must leave at least one training and one held-out query in each class; otherwise a
|
|
19
|
+
:class:`ValueError` is raised before any split is produced.
|
|
20
|
+
|
|
21
|
+
Each class is shuffled with a fixed-seed local RNG first, so a positive split is
|
|
22
|
+
reproducible but independent of how the eval file happens to be ordered (otherwise
|
|
23
|
+
the held-out set is just the first N as written, which clusters similar queries).
|
|
24
|
+
Returning positional indices (not query sublists) keeps the dedup invariant intact
|
|
25
|
+
and lets callers slice ``per_query`` by the same indices without matching on text.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
eval_set: All evaluation queries.
|
|
29
|
+
test_frac: Fraction of each class to hold out for testing; ``0`` disables it.
|
|
30
|
+
seed: RNG seed for the per-class shuffle.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A ``(train_idx, test_idx)`` tuple of original positional indices into
|
|
34
|
+
``eval_set``. The two lists are disjoint and together cover every index.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If ``test_frac`` is non-finite or outside ``[0, 1)``, or (for a
|
|
38
|
+
positive fraction) a class has fewer than two queries or cannot retain a
|
|
39
|
+
training query at the requested fraction.
|
|
40
|
+
"""
|
|
41
|
+
if not math.isfinite(test_frac) or not 0 <= test_frac < 1:
|
|
42
|
+
raise ValueError("holdout must be finite and satisfy 0 <= holdout < 1")
|
|
43
|
+
if test_frac == 0:
|
|
44
|
+
# Zero disables the held-out set: all-train, no-test, original order preserved,
|
|
45
|
+
# with no class-balance requirement (mirrors skill-creator's --holdout 0).
|
|
46
|
+
return list(range(len(eval_set))), []
|
|
47
|
+
positive = [i for i, q in enumerate(eval_set) if q["should_trigger"]]
|
|
48
|
+
negative = [i for i, q in enumerate(eval_set) if not q["should_trigger"]]
|
|
49
|
+
for group, label in ((positive, "positive"), (negative, "negative")):
|
|
50
|
+
if len(group) < 2:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"{label} class must contain at least 2 queries when holdout > 0"
|
|
53
|
+
)
|
|
54
|
+
# Deterministic, reproducible train/test split -- not a security/crypto use, so
|
|
55
|
+
# the standard (non-cryptographic) RNG is the correct choice here.
|
|
56
|
+
rng = random.Random(seed) # noqa: S311 # nosec B311
|
|
57
|
+
rng.shuffle(positive)
|
|
58
|
+
rng.shuffle(negative)
|
|
59
|
+
train: list[int] = []
|
|
60
|
+
test: list[int] = []
|
|
61
|
+
for group, label in ((positive, "positive"), (negative, "negative")):
|
|
62
|
+
n_test = max(1, round(len(group) * test_frac))
|
|
63
|
+
if n_test >= len(group):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"{label} class cannot retain a training query at this holdout"
|
|
66
|
+
)
|
|
67
|
+
test += group[:n_test]
|
|
68
|
+
train += group[n_test:]
|
|
69
|
+
return train, test
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_models(spec: str | None) -> list[str]:
|
|
73
|
+
"""Resolve a comma-separated model spec into a list of full model ids.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
spec: Comma-separated aliases/ids (e.g. ``"haiku,sonnet"``), or ``None``.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Resolved model ids. Defaults to ``[sonnet]`` when ``spec`` is empty/``None``;
|
|
80
|
+
a spec of only separators (e.g. ``","``) yields an empty list.
|
|
81
|
+
"""
|
|
82
|
+
if not spec:
|
|
83
|
+
return [MODEL_ALIASES["sonnet"]]
|
|
84
|
+
return [
|
|
85
|
+
MODEL_ALIASES.get(s.strip(), s.strip()) for s in spec.split(",") if s.strip()
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _short_model(model: str) -> str:
|
|
90
|
+
"""Return a model's short display name (the segment after the first ``-``).
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
model: A full model id (e.g. ``"claude-sonnet-5"``) or bare name.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The short name (e.g. ``"sonnet"``), or the full name if it has no ``-``.
|
|
97
|
+
"""
|
|
98
|
+
return model.split("-")[1] if "-" in model else model
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def summarize(tag: str, ev: EvalResult) -> str:
|
|
102
|
+
"""Render a one-line summary of an evaluation result.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tag: Short label for the line (e.g. ``"BASELINE"``).
|
|
106
|
+
ev: The evaluation result to summarize.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A line of the form ``"<tag>: mean=… min=… (model=acc …)"`` where each model
|
|
110
|
+
is shown by its short name; a model with no judged query renders as ``n/a``.
|
|
111
|
+
"""
|
|
112
|
+
per_model = " ".join(
|
|
113
|
+
f"{_short_model(m)}={'n/a' if a is None else f'{a:.2f}'}"
|
|
114
|
+
for m, a in ev["per_model_accuracy"].items()
|
|
115
|
+
)
|
|
116
|
+
return (
|
|
117
|
+
f"{tag}: mean={ev['mean_accuracy']:.3f} "
|
|
118
|
+
f"min={ev['min_accuracy']:.3f} ({per_model})"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def summarize_verbose(tag: str, ev: EvalResult) -> str:
|
|
123
|
+
"""Render a multi-line, skill-creator-style precision/recall/accuracy summary.
|
|
124
|
+
|
|
125
|
+
Leads with the one-line :func:`summarize`, then an aggregate confusion header, a
|
|
126
|
+
per-model precision/recall/accuracy line, and a PASS/FAIL/N-A line per query. A
|
|
127
|
+
fully-unjudged model (its ``per_model_accuracy`` is ``None``) renders as ``n/a``,
|
|
128
|
+
as does the aggregate when every model is unjudged, mirroring :func:`summarize`'s
|
|
129
|
+
tri-state guard instead of the misleading ``100%/100%/0%`` an all-zeros confusion
|
|
130
|
+
matrix would otherwise show.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
tag: Short label for the block (e.g. ``"BASELINE"``).
|
|
134
|
+
ev: The evaluation result to summarize.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The multi-line summary.
|
|
138
|
+
"""
|
|
139
|
+
c = ev["confusion"]
|
|
140
|
+
judged = c["tp"] + c["fp"] + c["tn"] + c["fn"]
|
|
141
|
+
agg = (
|
|
142
|
+
" aggregate: n/a"
|
|
143
|
+
if judged == 0
|
|
144
|
+
else f" aggregate: {c['tp'] + c['tn']}/{judged} correct "
|
|
145
|
+
f"precision={c['precision']:.0%} recall={c['recall']:.0%} "
|
|
146
|
+
f"accuracy={c['accuracy']:.0%}"
|
|
147
|
+
)
|
|
148
|
+
lines = [summarize(tag, ev), agg]
|
|
149
|
+
acc = ev["per_model_accuracy"]
|
|
150
|
+
for m, cm in ev["per_model_confusion"].items():
|
|
151
|
+
if acc[m] is None:
|
|
152
|
+
lines.append(f" {_short_model(m)}: n/a")
|
|
153
|
+
else:
|
|
154
|
+
lines.append(
|
|
155
|
+
f" {_short_model(m)}: precision={cm['precision']:.0%} "
|
|
156
|
+
f"recall={cm['recall']:.0%} accuracy={cm['accuracy']:.0%}"
|
|
157
|
+
)
|
|
158
|
+
for pq in ev["per_query"]:
|
|
159
|
+
status = (
|
|
160
|
+
"PASS"
|
|
161
|
+
if pq["all_pass"] is True
|
|
162
|
+
else "FAIL"
|
|
163
|
+
if pq["all_pass"] is False
|
|
164
|
+
else "N/A "
|
|
165
|
+
)
|
|
166
|
+
lines.append(
|
|
167
|
+
f" [{status}] expected={pq['should_trigger']}: {pq['query'][:60]}"
|
|
168
|
+
)
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_better_candidate(
|
|
173
|
+
cand_mean: float,
|
|
174
|
+
cand_min: float,
|
|
175
|
+
best_mean: float,
|
|
176
|
+
best_min: float,
|
|
177
|
+
epsilon: float,
|
|
178
|
+
cand_chars: int | None = None,
|
|
179
|
+
best_chars: int | None = None,
|
|
180
|
+
max_chars: int | None = None,
|
|
181
|
+
*,
|
|
182
|
+
score_label: str = "held-out",
|
|
183
|
+
) -> tuple[bool, str]:
|
|
184
|
+
"""Decide whether a candidate beats the incumbent on the selection score.
|
|
185
|
+
|
|
186
|
+
The primary criterion is the selection *mean* accuracy (selecting on held-out
|
|
187
|
+
rather than train is what avoids overfitting the eval set). But on a small
|
|
188
|
+
selection split a sub-``epsilon`` mean difference is noise, not signal -- so when
|
|
189
|
+
two descriptions tie within ``epsilon``, break the tie in favor of the higher
|
|
190
|
+
*min* (weakest-model) accuracy. Set ``epsilon=0`` for strict mean-only selection.
|
|
191
|
+
|
|
192
|
+
When ``max_chars`` is set the character budget is a HARD constraint that dominates
|
|
193
|
+
the score comparison: an over-budget candidate can never win, a legal candidate
|
|
194
|
+
beats an over-budget incumbent even at an equal-or-slightly-lower mean (the
|
|
195
|
+
incumbent cannot ship), and among two legal ties within ``epsilon`` the shorter
|
|
196
|
+
wins. Passing no ``*_chars`` (the 5-argument call) leaves length out entirely and
|
|
197
|
+
reproduces the pre-budget behavior byte-for-byte.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
cand_mean: Candidate selection mean accuracy.
|
|
201
|
+
cand_min: Candidate selection min (weakest-model) accuracy.
|
|
202
|
+
best_mean: Incumbent selection mean accuracy.
|
|
203
|
+
best_min: Incumbent selection min accuracy.
|
|
204
|
+
epsilon: Mean-accuracy band within which scores count as tied.
|
|
205
|
+
cand_chars: Candidate description length, for the budget constraint.
|
|
206
|
+
best_chars: Incumbent description length, for the budget constraint.
|
|
207
|
+
max_chars: Hard character ceiling, or ``None`` to ignore length entirely.
|
|
208
|
+
score_label: Name of the selection score used in the reason strings
|
|
209
|
+
(``"held-out"`` with a holdout, ``"train"`` when the holdout is disabled).
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
``(win, reason)`` where ``win`` is ``True`` if the candidate should replace
|
|
213
|
+
the incumbent, and ``reason`` is a short human-readable explanation.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def _over(chars: int | None) -> bool:
|
|
217
|
+
return max_chars is not None and chars is not None and chars > max_chars
|
|
218
|
+
|
|
219
|
+
if _over(cand_chars):
|
|
220
|
+
return False, f"over char budget ({cand_chars} > {max_chars})"
|
|
221
|
+
if _over(best_chars):
|
|
222
|
+
# Incumbent can't ship; any legal candidate replaces it -- ideally with a
|
|
223
|
+
# better/tied mean, but even a slightly lower one beats unshippable.
|
|
224
|
+
rel = "non-regressing" if cand_mean >= best_mean - epsilon else "lower"
|
|
225
|
+
return True, (
|
|
226
|
+
f"incumbent over budget ({best_chars} > {max_chars}); adopting legal "
|
|
227
|
+
f"candidate ({cand_chars} chars) with {rel} {score_label} mean "
|
|
228
|
+
f"({cand_mean:.3f} vs {best_mean:.3f})"
|
|
229
|
+
)
|
|
230
|
+
if cand_mean > best_mean + epsilon:
|
|
231
|
+
return True, f"improved {score_label} mean ({cand_mean:.3f} > {best_mean:.3f})"
|
|
232
|
+
if epsilon > 0 and abs(cand_mean - best_mean) <= epsilon and cand_min > best_min:
|
|
233
|
+
return True, (
|
|
234
|
+
f"{score_label} mean within {epsilon:.3f} "
|
|
235
|
+
f"({cand_mean:.3f} vs {best_mean:.3f}); "
|
|
236
|
+
f"better weakest-model min ({cand_min:.3f} > {best_min:.3f})"
|
|
237
|
+
)
|
|
238
|
+
if (
|
|
239
|
+
max_chars is not None
|
|
240
|
+
and abs(cand_mean - best_mean) <= epsilon
|
|
241
|
+
and cand_chars is not None
|
|
242
|
+
and best_chars is not None
|
|
243
|
+
and cand_chars < best_chars
|
|
244
|
+
):
|
|
245
|
+
return True, (
|
|
246
|
+
f"{score_label} mean within {epsilon:.3f} "
|
|
247
|
+
f"({cand_mean:.3f} vs {best_mean:.3f}); "
|
|
248
|
+
f"shorter description preferred ({cand_chars} < {best_chars} chars)"
|
|
249
|
+
)
|
|
250
|
+
return False, f"no {score_label} gain"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Parsing and writing of a skill's ``SKILL.md`` frontmatter."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# YAML block/folded scalar indicators that carry no inline text on the ``key:`` line.
|
|
7
|
+
_BLOCK_SCALAR_INDICATORS = frozenset({"|", ">", "|-", ">-", "|+", ">+", ""})
|
|
8
|
+
|
|
9
|
+
# Characters kept verbatim when a skill name is reduced to a filesystem path token.
|
|
10
|
+
# Every other character — path separators, whitespace, shell/format metacharacters —
|
|
11
|
+
# collapses to a single ``_``. A shared skill's ``name:`` is attacker-controlled, so it
|
|
12
|
+
# must never reach a filename verbatim.
|
|
13
|
+
_UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
14
|
+
|
|
15
|
+
# Cap on the path-token length so a pathologically long ``name:`` cannot produce an
|
|
16
|
+
# over-long filename that the OS rejects with ``ENAMETOOLONG`` mid-run.
|
|
17
|
+
_MAX_NAME_TOKEN_CHARS = 64
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe_name_token(name: str, *, fallback: str = "skill") -> str:
|
|
21
|
+
"""Reduce a skill name to a filesystem-safe token for use as a path component.
|
|
22
|
+
|
|
23
|
+
A shared ``SKILL.md``'s ``name:`` frontmatter is attacker-controlled, so it must
|
|
24
|
+
never reach a filename verbatim: an absolute path, a ``../`` sequence, or a bare path
|
|
25
|
+
separator would let a downstream write escape its intended directory. This collapses
|
|
26
|
+
every run of characters outside ``[A-Za-z0-9._-]`` to a single ``_``, strips leading
|
|
27
|
+
and trailing separators/dots (so a name that is only dots or separators — e.g. ``..``
|
|
28
|
+
or ``/`` — can never yield ``.``, ``..``, or an empty component), caps the length,
|
|
29
|
+
and falls back to a fixed token when nothing usable remains.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: The raw, possibly attacker-controlled skill name.
|
|
33
|
+
fallback: Token returned when ``name`` has no filesystem-safe characters.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
A token drawn only from ``[A-Za-z0-9._-]``, never empty, never ``.`` or ``..``,
|
|
37
|
+
and never containing a path separator.
|
|
38
|
+
"""
|
|
39
|
+
token = _UNSAFE_NAME_CHARS.sub("_", name).strip("._-")[:_MAX_NAME_TOKEN_CHARS]
|
|
40
|
+
return token.strip("._-") or fallback
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_skill_md(skill_md: Path) -> tuple[str, str, str]:
|
|
44
|
+
"""Extract the name, description, and body from a ``SKILL.md`` file.
|
|
45
|
+
|
|
46
|
+
Handles inline, folded (``>``), and block (``|``) scalar description styles, and
|
|
47
|
+
collapses internal whitespace in the description to single spaces.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
skill_md: Path to the ``SKILL.md`` file.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A ``(name, description, body)`` tuple. ``name`` is empty when absent.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If the file has no ``---`` delimited YAML frontmatter.
|
|
57
|
+
"""
|
|
58
|
+
text = skill_md.read_text()
|
|
59
|
+
match = re.search(r"^---\n(.*?)\n---\n?(.*)$", text, re.DOTALL)
|
|
60
|
+
if not match:
|
|
61
|
+
raise ValueError(f"No YAML frontmatter in {skill_md}")
|
|
62
|
+
frontmatter, body = match[1], match[2]
|
|
63
|
+
|
|
64
|
+
name = ""
|
|
65
|
+
if name_match := re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE):
|
|
66
|
+
name = name_match[1].strip().strip("'\"")
|
|
67
|
+
|
|
68
|
+
desc_parts: list[str] = []
|
|
69
|
+
capturing = False
|
|
70
|
+
for line in frontmatter.splitlines():
|
|
71
|
+
if not capturing:
|
|
72
|
+
if desc_match := re.match(r"^description:\s*(.*)$", line):
|
|
73
|
+
capturing = True
|
|
74
|
+
rest = desc_match[1].strip()
|
|
75
|
+
if rest not in _BLOCK_SCALAR_INDICATORS:
|
|
76
|
+
desc_parts.append(rest)
|
|
77
|
+
continue
|
|
78
|
+
# Continuation: indented or blank lines belong to the description; the first
|
|
79
|
+
# non-indented, non-blank line is the next top-level key and ends capture.
|
|
80
|
+
if re.match(r"^\s+\S", line) or line.strip() == "":
|
|
81
|
+
desc_parts.append(line.strip())
|
|
82
|
+
else:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
description = re.sub(r"\s+", " ", " ".join(p for p in desc_parts if p)).strip()
|
|
86
|
+
return name, description, body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_description(skill_md: Path, new_description: str) -> None:
|
|
90
|
+
"""Rewrite the description in ``SKILL.md`` as a literal block scalar (``|``).
|
|
91
|
+
|
|
92
|
+
The original file is backed up to ``<name>.bak`` first. The new description is
|
|
93
|
+
appended after the remaining frontmatter keys (so key order may change).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
skill_md: Path to the ``SKILL.md`` file to modify.
|
|
97
|
+
new_description: The replacement description text.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If the file has no ``---`` delimited YAML frontmatter. Note the
|
|
101
|
+
backup is written before this check, so a failed write still leaves a
|
|
102
|
+
``.bak`` behind.
|
|
103
|
+
"""
|
|
104
|
+
text = skill_md.read_text()
|
|
105
|
+
skill_md.with_suffix(f"{skill_md.suffix}.bak").write_text(text)
|
|
106
|
+
match = re.search(r"^---\n(.*?)\n---", text, re.DOTALL)
|
|
107
|
+
if not match:
|
|
108
|
+
raise ValueError(f"No frontmatter in {skill_md}")
|
|
109
|
+
frontmatter = match[1]
|
|
110
|
+
# Drop the existing description block (``description:`` up to the next top-level
|
|
111
|
+
# key or end of frontmatter).
|
|
112
|
+
fm_without_desc = re.sub(
|
|
113
|
+
r"^description:.*?(?=^\w[\w-]*:|\Z)",
|
|
114
|
+
"",
|
|
115
|
+
frontmatter + "\n",
|
|
116
|
+
flags=re.DOTALL | re.MULTILINE,
|
|
117
|
+
).rstrip("\n")
|
|
118
|
+
folded = "\n ".join(new_description.split("\n"))
|
|
119
|
+
new_frontmatter = (
|
|
120
|
+
fm_without_desc + "\n" if fm_without_desc else ""
|
|
121
|
+
) + f"description: |\n {folded}"
|
|
122
|
+
new_text = text[: match.start(1)] + new_frontmatter + text[match.end(1) :]
|
|
123
|
+
skill_md.write_text(new_text)
|