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.
@@ -0,0 +1,475 @@
1
+ """Triggering evaluation: drive ``claude -p`` per query and aggregate results."""
2
+
3
+ import copy
4
+ import json
5
+ import logging
6
+ import os
7
+ import select
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ import uuid
12
+ from collections.abc import Iterator, Mapping
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from skill_optimizer._process import claude_bin, subprocess_env
18
+ from skill_optimizer.interpreter import (
19
+ _interpret_events_status, # pyright: ignore[reportPrivateUsage]
20
+ )
21
+ from skill_optimizer.models import (
22
+ ConfusionMatrix,
23
+ EvalConfig,
24
+ EvalQuery,
25
+ EvalResult,
26
+ ModelResult,
27
+ PerQuery,
28
+ )
29
+ from skill_optimizer.skill_md import safe_name_token
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _READ_CHUNK = 8192
34
+
35
+
36
+ def _stream_events(
37
+ proc: subprocess.Popen[bytes], timeout: float
38
+ ) -> Iterator[dict[str, Any]]:
39
+ """Yield parsed stream-json events from a running ``claude -p`` process.
40
+
41
+ Reads stdout incrementally and yields one decoded JSON object per complete line,
42
+ skipping blanks and undecodable lines, until ``timeout`` elapses or the process
43
+ exits with a drained buffer. Pairs with :func:`interpret_events`, which may stop
44
+ consuming early once the outcome is decided.
45
+
46
+ Args:
47
+ proc: The running subprocess, with ``stdout`` piped as bytes.
48
+ timeout: Wall-clock budget in seconds.
49
+
50
+ Yields:
51
+ Decoded stream-json event objects, in order.
52
+ """
53
+ # Type-narrowing, not a runtime guard: proc is always constructed with
54
+ # stdout=PIPE, which guarantees a non-None stream.
55
+ assert proc.stdout is not None # noqa: S101 # nosec B101
56
+ start = time.monotonic()
57
+ buffer = ""
58
+ while time.monotonic() - start < timeout:
59
+ if proc.poll() is None:
60
+ ready, _, _ = select.select([proc.stdout], [], [], 1.0)
61
+ if not ready:
62
+ continue
63
+ if chunk := os.read(proc.stdout.fileno(), _READ_CHUNK):
64
+ buffer += chunk.decode("utf-8", "replace")
65
+ elif remainder := proc.stdout.read():
66
+ buffer += remainder.decode("utf-8", "replace")
67
+ while "\n" in buffer:
68
+ line, buffer = buffer.split("\n", 1)
69
+ line = line.strip()
70
+ if not line:
71
+ continue
72
+ try:
73
+ yield json.loads(line)
74
+ except json.JSONDecodeError:
75
+ continue
76
+ if proc.poll() is not None and "\n" not in buffer:
77
+ break
78
+ if line := buffer.strip():
79
+ try:
80
+ yield json.loads(line)
81
+ except json.JSONDecodeError:
82
+ pass
83
+
84
+
85
+ def run_single_query(
86
+ query: str,
87
+ skill_name: str,
88
+ description: str,
89
+ model: str | None,
90
+ timeout: int,
91
+ settings_json: str | None,
92
+ ) -> bool | None:
93
+ """Run one eval query and report whether the model invoked the candidate skill.
94
+
95
+ Injects ``description`` as a temporary slash-command in a throwaway project, runs
96
+ ``claude -p`` against ``query``, and inspects the streamed tool-call intent.
97
+
98
+ Args:
99
+ query: The task to send to the model.
100
+ skill_name: Name of the skill (used to build the candidate command name).
101
+ description: The candidate description to inject.
102
+ model: Model id, or ``None`` to use the CLI default.
103
+ timeout: Per-run wall-clock budget, in seconds.
104
+ settings_json: Optional ``--settings`` JSON blob.
105
+
106
+ Returns:
107
+ ``True`` if the model decided to invoke the injected command, ``False`` on a
108
+ decisive non-trigger, or ``None`` when the probe is unjudgeable (timeout, a
109
+ non-zero ``claude -p`` exit, or an empty/incomplete stream with no decisive
110
+ terminal event) so callers can exclude it rather than score it as a miss.
111
+ """
112
+ rid = uuid.uuid4().hex[:8]
113
+ # ``skill_name`` comes from attacker-controlled SKILL.md frontmatter; sanitize it to
114
+ # a path-safe token before it becomes the slash-command filename, so a hostile name
115
+ # (``/abs/path``, ``../..``) cannot escape the throwaway project dir. The same token
116
+ # is the command name detected in the stream, so filename and detection stay aligned.
117
+ cmd_name = f"{safe_name_token(skill_name)}-cand-{rid}"
118
+ with tempfile.TemporaryDirectory(
119
+ prefix=f"skilleval-{rid}-", ignore_cleanup_errors=True
120
+ ) as tmp:
121
+ commands_dir = Path(tmp) / ".claude" / "commands"
122
+ commands_dir.mkdir(parents=True, exist_ok=True)
123
+ indented = "\n ".join(description.split("\n"))
124
+ (commands_dir / f"{cmd_name}.md").write_text(
125
+ f"---\ndescription: |\n {indented}\n---\n\n"
126
+ f"# {skill_name}\n\nThis skill handles: {description}\n"
127
+ )
128
+ cmd = [
129
+ claude_bin(),
130
+ "-p",
131
+ query,
132
+ "--output-format",
133
+ "stream-json",
134
+ "--verbose",
135
+ "--include-partial-messages",
136
+ ]
137
+ if model:
138
+ cmd += ["--model", model]
139
+ if settings_json:
140
+ cmd += ["--settings", settings_json]
141
+ # cmd is a fixed argv list built from internal constants and CLI-provided
142
+ # model/settings strings, never a shell string -- no shell=True, no injection
143
+ # surface.
144
+ proc = subprocess.Popen( # noqa: S603 # nosec B603
145
+ cmd,
146
+ stdout=subprocess.PIPE,
147
+ stderr=subprocess.DEVNULL,
148
+ cwd=tmp,
149
+ env=subprocess_env(),
150
+ )
151
+ try:
152
+ # A decisive terminal event (True/False) wins regardless of exit code; a
153
+ # ``None`` means no decisive event was seen (timeout, empty/incomplete
154
+ # output, or a failed process) -> the probe is unjudgeable, not a genuine
155
+ # non-trigger, so it propagates as ``None``.
156
+ return _interpret_events_status(_stream_events(proc, timeout), cmd_name)
157
+ finally:
158
+ if proc.poll() is None:
159
+ proc.kill()
160
+ proc.wait()
161
+
162
+
163
+ def run_query_with_retry(
164
+ query: str,
165
+ skill_name: str,
166
+ description: str,
167
+ model: str | None,
168
+ timeout: int,
169
+ settings_json: str | None,
170
+ retries: int = 1,
171
+ ) -> bool | None:
172
+ """Probe triggering, retrying unjudgeable (``None``) probes up to ``retries`` times.
173
+
174
+ A one-off timeout or CLI hiccup returns ``None`` from :func:`run_single_query`;
175
+ retrying keeps a transient failure from being recorded (and later scored) as a
176
+ non-trigger. A decisive ``True``/``False`` is returned immediately.
177
+
178
+ Args:
179
+ query: The task to send to the model.
180
+ skill_name: Name of the skill (used to build the candidate command name).
181
+ description: The candidate description to inject.
182
+ model: Model id, or ``None`` to use the CLI default.
183
+ timeout: Per-run wall-clock budget, in seconds.
184
+ settings_json: Optional ``--settings`` JSON blob.
185
+ retries: Extra attempts after the first when a probe is unjudgeable.
186
+
187
+ Returns:
188
+ ``True``/``False`` from the first decisive probe, or ``None`` if every attempt
189
+ was unjudgeable.
190
+ """
191
+ for _ in range(retries + 1):
192
+ result = run_single_query(
193
+ query, skill_name, description, model, timeout, settings_json
194
+ )
195
+ if result is not None:
196
+ return result
197
+ return None
198
+
199
+
200
+ def _confusion_matrix(tp: int, fp: int, tn: int, fn: int) -> ConfusionMatrix:
201
+ """Build a :class:`ConfusionMatrix` from raw counts.
202
+
203
+ Args:
204
+ tp: True positives.
205
+ fp: False positives.
206
+ tn: True negatives.
207
+ fn: False negatives.
208
+
209
+ Returns:
210
+ The confusion matrix with precision/recall/accuracy, guarding divide-by-zero
211
+ the same way skill-creator does (empty predicted/expected positives ⇒ ``1.0``).
212
+ """
213
+ total = tp + fp + tn + fn
214
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
215
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
216
+ accuracy = (tp + tn) / total if total > 0 else 0.0
217
+ return {
218
+ "tp": tp,
219
+ "fp": fp,
220
+ "tn": tn,
221
+ "fn": fn,
222
+ "precision": round(precision, 4),
223
+ "recall": round(recall, 4),
224
+ "accuracy": round(accuracy, 4),
225
+ }
226
+
227
+
228
+ def aggregate(
229
+ raw: Mapping[tuple[int, str], list[bool]],
230
+ eval_set: list[EvalQuery],
231
+ models: tuple[str, ...],
232
+ threshold: float,
233
+ description: str,
234
+ errors: Mapping[tuple[int, str], int] | None = None,
235
+ ) -> EvalResult:
236
+ """Roll raw per-run trigger outcomes up into accuracy scores.
237
+
238
+ Cells with no judged runs (every probe was unjudgeable) are left ``pass=None`` and
239
+ excluded from the accuracy denominators, so a transient CLI outage does not read as
240
+ a wave of misses. Accuracy is meaned/mined only over models that have at least one
241
+ judged query; if no model does, ``score_valid`` is ``False``.
242
+
243
+ Args:
244
+ raw: Trigger booleans keyed by ``(query_index, model)``.
245
+ eval_set: The queries, indexed positionally to match ``raw`` keys.
246
+ models: Models that were evaluated.
247
+ threshold: Trigger-rate at or above which a query counts as triggered.
248
+ description: The description these results belong to (echoed in the output).
249
+ errors: Per-cell unjudged-probe counts keyed by ``(query_index, model)``.
250
+
251
+ Returns:
252
+ The aggregated :class:`EvalResult`.
253
+ """
254
+ err_map = errors or {}
255
+ per_query: list[PerQuery] = []
256
+ for i, q in enumerate(eval_set):
257
+ md: dict[str, ModelResult] = {}
258
+ for m in models:
259
+ trig = raw.get((i, m), [])
260
+ n = len(trig)
261
+ triggers = sum(trig)
262
+ if n:
263
+ rate = triggers / n
264
+ passed = (
265
+ (rate >= threshold) if q["should_trigger"] else (rate < threshold)
266
+ )
267
+ else:
268
+ # Every probe for this cell was unjudgeable -> leave it out of scoring.
269
+ rate, passed = 0.0, None
270
+ md[m] = {
271
+ "trigger_rate": round(rate, 3),
272
+ "pass": passed,
273
+ "triggers": triggers,
274
+ "runs": n,
275
+ "errors": err_map.get((i, m), 0),
276
+ }
277
+ judged = [md[m]["pass"] for m in models if md[m]["pass"] is not None]
278
+ per_query.append(
279
+ {
280
+ "index": i,
281
+ "query": q["query"],
282
+ "should_trigger": q["should_trigger"],
283
+ "models": md,
284
+ # None when every model was unjudged: neither a decisive pass nor a
285
+ # decisive failure, so it must not read as a falsey miss downstream.
286
+ "all_pass": all(judged) if judged else None,
287
+ }
288
+ )
289
+ return _rollup_stats(description, per_query, models)
290
+
291
+
292
+ def _rollup_stats(
293
+ description: str, per_query: list[PerQuery], models: tuple[str, ...]
294
+ ) -> EvalResult:
295
+ """Roll per-query model outcomes up into accuracy, confusion, and error totals.
296
+
297
+ Shared by :func:`aggregate` (from raw probes) and :func:`subset_result` (from a
298
+ sliced copy) so both derive identical stats from the tri-state ``per_query`` data.
299
+
300
+ Args:
301
+ description: The description these results belong to.
302
+ per_query: The per-query roll-ups to summarize (owned by the caller).
303
+ models: The evaluated model ids.
304
+
305
+ Returns:
306
+ The aggregated :class:`EvalResult`.
307
+ """
308
+ per_model_correct = {m: 0 for m in models}
309
+ per_model_total = {m: 0 for m in models}
310
+ conf = {m: {"tp": 0, "fp": 0, "tn": 0, "fn": 0} for m in models}
311
+ total_errors = 0
312
+ unjudged = 0
313
+ for pq in per_query:
314
+ for m in models:
315
+ mr = pq["models"][m]
316
+ total_errors += mr["errors"]
317
+ if mr["pass"] is None:
318
+ unjudged += 1
319
+ continue
320
+ per_model_total[m] += 1
321
+ per_model_correct[m] += int(mr["pass"])
322
+ if pq["should_trigger"]:
323
+ conf[m]["tp"] += mr["triggers"]
324
+ conf[m]["fn"] += mr["runs"] - mr["triggers"]
325
+ else:
326
+ conf[m]["fp"] += mr["triggers"]
327
+ conf[m]["tn"] += mr["runs"] - mr["triggers"]
328
+ per_model_acc: dict[str, float | None] = {
329
+ m: (round(per_model_correct[m] / per_model_total[m], 4))
330
+ if per_model_total[m]
331
+ else None
332
+ for m in models
333
+ }
334
+ if judged_accs := [a for a in per_model_acc.values() if a is not None]:
335
+ mean_acc = round(sum(judged_accs) / len(judged_accs), 4)
336
+ min_acc = round(min(judged_accs), 4)
337
+ score_valid = True
338
+ else:
339
+ mean_acc = min_acc = 0.0
340
+ score_valid = False
341
+ per_model_confusion = {
342
+ m: _confusion_matrix(conf[m]["tp"], conf[m]["fp"], conf[m]["tn"], conf[m]["fn"])
343
+ for m in models
344
+ }
345
+ agg = _confusion_matrix(
346
+ sum(c["tp"] for c in conf.values()),
347
+ sum(c["fp"] for c in conf.values()),
348
+ sum(c["tn"] for c in conf.values()),
349
+ sum(c["fn"] for c in conf.values()),
350
+ )
351
+ return {
352
+ "description": description,
353
+ "per_model_accuracy": per_model_acc,
354
+ "mean_accuracy": mean_acc,
355
+ "min_accuracy": min_acc,
356
+ "per_query": per_query,
357
+ "errors": total_errors,
358
+ "unjudged": unjudged,
359
+ "score_valid": score_valid,
360
+ "confusion": agg,
361
+ "per_model_confusion": per_model_confusion,
362
+ }
363
+
364
+
365
+ def subset_result(
366
+ full_eval: EvalResult,
367
+ eval_set: list[EvalQuery],
368
+ indices: list[int],
369
+ models: tuple[str, ...],
370
+ ) -> EvalResult:
371
+ """Derive a train/test view of one full-set evaluation, sliced by index.
372
+
373
+ Deep-copies the :data:`PerQuery` entries at ``indices`` (they are mutable, so a
374
+ shallow slice would let train/test/history views cross-mutate) and recomputes every
375
+ stat from the copies, honoring the tri-state contract (unjudged cells excluded).
376
+ Slicing by positional index — never query text — preserves the dedup invariant.
377
+
378
+ Args:
379
+ full_eval: The full-set evaluation to slice.
380
+ eval_set: The full eval set; its length must match ``full_eval["per_query"]``.
381
+ indices: Original positional indices to include (unique, in range).
382
+ models: The evaluated model ids.
383
+
384
+ Returns:
385
+ An :class:`EvalResult` over just the selected queries.
386
+
387
+ Raises:
388
+ ValueError: If the per-query length disagrees with ``eval_set``, or an index is
389
+ out of range or duplicated.
390
+ """
391
+ n = len(eval_set)
392
+ if len(full_eval["per_query"]) != n:
393
+ raise ValueError(
394
+ f"per_query length {len(full_eval['per_query'])} != eval_set length {n}"
395
+ )
396
+ seen: set[int] = set()
397
+ for i in indices:
398
+ if not 0 <= i < n:
399
+ raise ValueError(f"index {i} out of range [0, {n})")
400
+ if i in seen:
401
+ raise ValueError(f"duplicate index {i}")
402
+ seen.add(i)
403
+ per_query = [copy.deepcopy(full_eval["per_query"][i]) for i in indices]
404
+ return _rollup_stats(full_eval["description"], per_query, models)
405
+
406
+
407
+ def evaluate(
408
+ eval_set: list[EvalQuery],
409
+ skill_name: str,
410
+ description: str,
411
+ config: EvalConfig,
412
+ *,
413
+ verbose: bool = True,
414
+ ) -> EvalResult:
415
+ """Evaluate one description across the eval set and models, concurrently.
416
+
417
+ Args:
418
+ eval_set: Queries to evaluate.
419
+ skill_name: Name of the skill under test.
420
+ description: The description to evaluate.
421
+ config: Shared run settings (models, repeats, timeout, workers, threshold).
422
+ verbose: Whether to log periodic progress.
423
+
424
+ Returns:
425
+ The aggregated :class:`EvalResult`.
426
+ """
427
+ jobs = [
428
+ (i, q, m)
429
+ for i, q in enumerate(eval_set)
430
+ for m in config.models
431
+ for _ in range(config.repeats)
432
+ ]
433
+ raw: dict[tuple[int, str], list[bool]] = {}
434
+ errors: dict[tuple[int, str], int] = {}
435
+ with ThreadPoolExecutor(max_workers=config.workers) as executor:
436
+ # Bucket by the query's positional index -- the same index aggregate()
437
+ # uses via enumerate(eval_set). (The original used eval_set.index(q),
438
+ # which is O(n^2) and mis-buckets exact-duplicate query dicts to the first
439
+ # match; positional indexing matches skill-creator's unique-query model and
440
+ # reports each entry's true rate.)
441
+ futures = {
442
+ executor.submit(
443
+ run_query_with_retry,
444
+ q["query"],
445
+ skill_name,
446
+ description,
447
+ m,
448
+ config.timeout,
449
+ config.settings_json,
450
+ ): (i, m)
451
+ for i, q, m in jobs
452
+ }
453
+ for done, fut in enumerate(as_completed(futures), start=1):
454
+ i, m = futures[fut]
455
+ try:
456
+ result = fut.result()
457
+ except Exception:
458
+ logger.warning("query %d (%s) failed", i, m, exc_info=True)
459
+ result = None
460
+ # A None probe is unjudgeable (timeout/CLI error): tally it separately and
461
+ # keep it out of the trigger-rate bucket rather than scoring it as a miss.
462
+ if result is None:
463
+ errors[(i, m)] = errors.get((i, m), 0) + 1
464
+ else:
465
+ raw.setdefault((i, m), []).append(result)
466
+ if verbose and done % 10 == 0:
467
+ logger.info(" ...%d/%d runs", done, len(jobs))
468
+ if total_errors := sum(errors.values()):
469
+ logger.info(
470
+ " %d probe(s) unjudgeable (timeout/CLI error); excluded from rates.",
471
+ total_errors,
472
+ )
473
+ return aggregate(
474
+ raw, eval_set, config.models, config.threshold, description, errors
475
+ )