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,636 @@
|
|
|
1
|
+
"""The description improver: prompt construction and the ``claude -p`` call."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from skill_optimizer._process import claude_bin, subprocess_env
|
|
12
|
+
from skill_optimizer.models import EvalResult, ImproverAttempt, PerQuery
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Hard limit on description length; longer descriptions get truncated downstream,
|
|
17
|
+
# so the improver output is rewritten to fit (mirrors skill-creator).
|
|
18
|
+
DESCRIPTION_CHAR_LIMIT = 1024
|
|
19
|
+
|
|
20
|
+
# Closed allowlist of retryable failure kinds -> their exact public messages. The
|
|
21
|
+
# messages are fixed, non-sensitive templates (no prompt/stdout/stderr/path), so a
|
|
22
|
+
# public failure ledger built only from these cannot disclose raw diagnostics.
|
|
23
|
+
_RETRYABLE_MESSAGES: dict[str, frozenset[str]] = {
|
|
24
|
+
"timeout": frozenset({"Improver timed out"}),
|
|
25
|
+
"invalid_output": frozenset(
|
|
26
|
+
{
|
|
27
|
+
"Improver returned no JSON",
|
|
28
|
+
"Improver JSON missing 'description'",
|
|
29
|
+
"Improver returned invalid JSON",
|
|
30
|
+
"Improver returned ambiguous JSON: multiple usable description objects",
|
|
31
|
+
}
|
|
32
|
+
),
|
|
33
|
+
"length_limit": frozenset(
|
|
34
|
+
{
|
|
35
|
+
"Improver description exceeded the configured character limit after shortening"
|
|
36
|
+
}
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ImproverRetryableError(ValueError):
|
|
42
|
+
"""A typed, retryable improver failure carrying only a validated kind + message.
|
|
43
|
+
|
|
44
|
+
Subclasses :class:`ValueError` so the parser's stable ``ValueError`` contract still
|
|
45
|
+
holds. The constructor rejects any kind/message outside :data:`_RETRYABLE_MESSAGES`,
|
|
46
|
+
so a public record built from these is provably non-sensitive.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, kind: str, message: str) -> None:
|
|
50
|
+
"""Validate ``kind``/``message`` against the closed allowlist.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
kind: One of ``timeout``/``invalid_output``/``length_limit``.
|
|
54
|
+
message: An exact message from that kind's allowlist.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If ``kind`` is unknown or ``message`` is not allowlisted for it.
|
|
58
|
+
"""
|
|
59
|
+
allowed = _RETRYABLE_MESSAGES.get(kind)
|
|
60
|
+
if allowed is None or message not in allowed:
|
|
61
|
+
raise ValueError(f"invalid retryable error: {kind!r}/{message!r}")
|
|
62
|
+
self.kind: str = kind
|
|
63
|
+
self.message: str = message
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ImproverFatalProcessError(RuntimeError):
|
|
68
|
+
"""A non-retryable improver failure: a completed nonzero exit or budget exhaustion.
|
|
69
|
+
|
|
70
|
+
Its message is a fixed template (an exit status or the budget line); raw child
|
|
71
|
+
stderr is never placed in it.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _LaunchBudget:
|
|
76
|
+
"""A mutable, decrementing ceiling on improver child-process launches.
|
|
77
|
+
|
|
78
|
+
:meth:`consume` is spent once at the low-level spawn boundary before each child, so
|
|
79
|
+
no caller can bypass the ceiling. Exhaustion is fatal (never retried).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, tokens: int) -> None:
|
|
83
|
+
"""Initialize the budget.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
tokens: The maximum number of child launches allowed.
|
|
87
|
+
"""
|
|
88
|
+
self._remaining = tokens
|
|
89
|
+
|
|
90
|
+
def consume(self) -> None:
|
|
91
|
+
"""Spend one launch token.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ImproverFatalProcessError: If no launch tokens remain.
|
|
95
|
+
"""
|
|
96
|
+
if self._remaining <= 0:
|
|
97
|
+
raise ImproverFatalProcessError("Improver launch budget exceeded")
|
|
98
|
+
self._remaining -= 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
IMPROVER_TEMPLATE = """\
|
|
102
|
+
You are tuning the `description` field of a Claude Code/Agent skill named "{name}".
|
|
103
|
+
The description is the ONLY text Claude sees when deciding whether to invoke this
|
|
104
|
+
skill — so it must clearly signal the tasks the skill is for, and clearly NOT match
|
|
105
|
+
adjacent tasks it is not for.
|
|
106
|
+
|
|
107
|
+
What the skill actually does (from its body, for accuracy — do not exceed this scope):
|
|
108
|
+
---
|
|
109
|
+
{body_excerpt}
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
Current description:
|
|
113
|
+
---
|
|
114
|
+
{description}
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
Evaluation of the current description (per model, trigger rate over repeated runs):
|
|
118
|
+
- A "should_trigger=true" query that didn't trigger is a FALSE NEGATIVE (under-triggering).
|
|
119
|
+
- A "should_trigger=false" query that did trigger is a FALSE POSITIVE (over-triggering).
|
|
120
|
+
|
|
121
|
+
Failing queries (where at least one model got it wrong):
|
|
122
|
+
{failures}
|
|
123
|
+
|
|
124
|
+
Per-model accuracy: {acc}
|
|
125
|
+
|
|
126
|
+
Descriptions already tried, with their training-set results (do NOT repeat these —
|
|
127
|
+
produce something structurally different in wording and emphasis):
|
|
128
|
+
{prior_attempts}
|
|
129
|
+
|
|
130
|
+
Rewrite the description to fix these failures. Guidance:
|
|
131
|
+
- Lead with concrete triggers: the verbs, file types, tool names, and concepts that
|
|
132
|
+
should fire this skill.
|
|
133
|
+
- To curb false positives, it's fine to state what the skill is NOT for when it
|
|
134
|
+
collides with an adjacent task.
|
|
135
|
+
- Keep it accurate to the skill body above; do not oversell.
|
|
136
|
+
- A little "pushiness" helps under-triggering, but pushiness that causes false
|
|
137
|
+
positives is a failure, not a win. Keep it tight (roughly 1-4 sentences), and spend
|
|
138
|
+
the words on the range of intents and concrete cues that should fire it - not on
|
|
139
|
+
quoting specific example queries, which overfits and tends to narrow triggering.
|
|
140
|
+
|
|
141
|
+
Return ONLY a JSON object, no prose, no code fences:
|
|
142
|
+
{{"description": "<new description>", "rationale": "<2-3 sentences on what you changed>"}}
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
# Max characters of skill body to include as context for the improver.
|
|
146
|
+
_BODY_EXCERPT_LIMIT = 1500
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _short_model(model: str) -> str:
|
|
150
|
+
"""Return a model's short display name (segment after the first ``-``).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
model: A full model id or bare name.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The short name, or the full name when there is no ``-``.
|
|
157
|
+
"""
|
|
158
|
+
return model.split("-")[1] if "-" in model else model
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _render_query_line(pq: PerQuery) -> str:
|
|
162
|
+
"""Render one training-query result line for a prior-attempt block.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
pq: The per-query roll-up (train-only) to render.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A ``[STATUS] "query" triggered N/M (model=n/m ...)`` line, where ``N/M`` are
|
|
169
|
+
summed across models and the parenthetical keeps each model's own rate.
|
|
170
|
+
"""
|
|
171
|
+
models = pq["models"]
|
|
172
|
+
triggers = sum(models[m]["triggers"] for m in models)
|
|
173
|
+
runs = sum(models[m]["runs"] for m in models)
|
|
174
|
+
status = (
|
|
175
|
+
"PASS"
|
|
176
|
+
if pq["all_pass"] is True
|
|
177
|
+
else "FAIL"
|
|
178
|
+
if pq["all_pass"] is False
|
|
179
|
+
else "N/A"
|
|
180
|
+
)
|
|
181
|
+
per_model = ", ".join(
|
|
182
|
+
f"{_short_model(m)}={models[m]['triggers']}/{models[m]['runs']}" for m in models
|
|
183
|
+
)
|
|
184
|
+
return (
|
|
185
|
+
f' [{status}] "{pq["query"][:80]}" triggered {triggers}/{runs} ({per_model})'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _render_prior_attempts(prior_attempts: Sequence[ImproverAttempt]) -> str:
|
|
190
|
+
"""Render tried descriptions as ``<attempt>`` blocks with their train results.
|
|
191
|
+
|
|
192
|
+
Only training-set results are rendered; held-out results are never included, so the
|
|
193
|
+
improver stays blind to the selection set.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
prior_attempts: The previously-tried attempts (train-only results).
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The rendered blocks, or ``"(none yet)"`` when there are no prior attempts.
|
|
200
|
+
"""
|
|
201
|
+
if not prior_attempts:
|
|
202
|
+
return "(none yet)"
|
|
203
|
+
blocks: list[str] = []
|
|
204
|
+
for att in prior_attempts:
|
|
205
|
+
desc = att["description"]
|
|
206
|
+
lines = [f"<attempt ({len(desc)} chars)>", f'description: "{desc}"']
|
|
207
|
+
if att["train_results"]:
|
|
208
|
+
lines.append("train results:")
|
|
209
|
+
lines.extend(_render_query_line(pq) for pq in att["train_results"])
|
|
210
|
+
lines.append("</attempt>")
|
|
211
|
+
blocks.append("\n".join(lines))
|
|
212
|
+
return "\n\n".join(blocks)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_improver_prompt(
|
|
216
|
+
name: str,
|
|
217
|
+
description: str,
|
|
218
|
+
body: str,
|
|
219
|
+
ev: EvalResult,
|
|
220
|
+
prior_attempts: Sequence[ImproverAttempt] = (),
|
|
221
|
+
) -> str:
|
|
222
|
+
"""Build the improver prompt from the current description and its failures.
|
|
223
|
+
|
|
224
|
+
Only training-set data is passed in (``ev`` is the training view and
|
|
225
|
+
``prior_attempts`` carry train-only results), so no held-out query text reaches the
|
|
226
|
+
improver — the selection set stays blind to avoid overfitting.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
name: The skill name.
|
|
230
|
+
description: The current description being improved.
|
|
231
|
+
body: The skill body; truncated to a fixed excerpt for context.
|
|
232
|
+
ev: Training-set evaluation of the current description; only decisively failing
|
|
233
|
+
queries (``all_pass is False``) are listed as failures.
|
|
234
|
+
prior_attempts: Descriptions already tried this run, each with its train-only
|
|
235
|
+
per-query results, listed so the improver avoids repeating them.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The fully formatted improver prompt.
|
|
239
|
+
"""
|
|
240
|
+
failures: list[str] = []
|
|
241
|
+
for pq in ev["per_query"]:
|
|
242
|
+
# Only decisive failures are shown; passes and unjudged queries are skipped.
|
|
243
|
+
if pq["all_pass"] is not False:
|
|
244
|
+
continue
|
|
245
|
+
rates = {m: pq["models"][m]["trigger_rate"] for m in pq["models"]}
|
|
246
|
+
kind = "should trigger" if pq["should_trigger"] else "should NOT trigger"
|
|
247
|
+
failures.append(
|
|
248
|
+
f'- ({kind}) "{pq["query"]}" trigger_rates={json.dumps(rates)}'
|
|
249
|
+
)
|
|
250
|
+
return IMPROVER_TEMPLATE.format(
|
|
251
|
+
name=name,
|
|
252
|
+
body_excerpt=body.strip()[:_BODY_EXCERPT_LIMIT],
|
|
253
|
+
description=description,
|
|
254
|
+
failures="\n".join(failures) or "(none — all queries pass)",
|
|
255
|
+
acc=json.dumps(ev["per_model_accuracy"]),
|
|
256
|
+
prior_attempts=_render_prior_attempts(prior_attempts),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# One shared decoder for span validation (see :func:`_decode_span`).
|
|
261
|
+
_DECODER = json.JSONDecoder()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _has_description(obj: dict[str, Any]) -> bool:
|
|
265
|
+
"""Return whether a decoded object carries a non-blank ``description``.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
obj: A decoded JSON object.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
``True`` when ``str(obj.get("description", "")).strip()`` is non-empty.
|
|
272
|
+
"""
|
|
273
|
+
return bool(str(obj.get("description", "")).strip())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _match_container_span(text: str, start: int) -> int:
|
|
277
|
+
"""Return the index of the closer matching the ``{``/``[`` opener at ``start``.
|
|
278
|
+
|
|
279
|
+
Walks a mixed brace/bracket stack, ignoring delimiters inside double-quoted JSON
|
|
280
|
+
strings. A double-quote toggles string state only when it is not escaped, i.e. when
|
|
281
|
+
the immediately preceding backslash run has even length.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
text: The cleaned response text.
|
|
285
|
+
start: Index of the ``{`` or ``[`` opener to match.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
The index of the matching closer.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ValueError: ``Improver returned invalid JSON`` when the opener has a mismatched
|
|
292
|
+
closer, an unclosed stack at end of text, or an unterminated string.
|
|
293
|
+
"""
|
|
294
|
+
stack: list[str] = []
|
|
295
|
+
in_string = False
|
|
296
|
+
escape = False
|
|
297
|
+
for index in range(start, len(text)):
|
|
298
|
+
char = text[index]
|
|
299
|
+
if in_string:
|
|
300
|
+
if escape:
|
|
301
|
+
escape = False
|
|
302
|
+
elif char == "\\":
|
|
303
|
+
escape = True
|
|
304
|
+
elif char == '"':
|
|
305
|
+
in_string = False
|
|
306
|
+
continue
|
|
307
|
+
if char == '"':
|
|
308
|
+
in_string = True
|
|
309
|
+
elif char in "{[":
|
|
310
|
+
stack.append(char)
|
|
311
|
+
elif char in "}]":
|
|
312
|
+
opener = stack.pop()
|
|
313
|
+
if (char == "}") != (opener == "{"):
|
|
314
|
+
raise ValueError("Improver returned invalid JSON")
|
|
315
|
+
if not stack:
|
|
316
|
+
return index
|
|
317
|
+
raise ValueError("Improver returned invalid JSON")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _decode_span(cleaned: str, start: int, end: int) -> tuple[bool, Any]:
|
|
321
|
+
"""Decode the balanced span ``cleaned[start:end + 1]`` as one JSON value.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
cleaned: The cleaned response text.
|
|
325
|
+
start: Index of the span's opener.
|
|
326
|
+
end: Index of the span's matching closer.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
``(True, value)`` if the span decodes to a complete JSON value that ends exactly
|
|
330
|
+
at ``end + 1``; otherwise ``(False, None)`` (a malformed or only-partially-valid
|
|
331
|
+
span, which the caller skips as a whole).
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
value, decode_end = _DECODER.raw_decode(cleaned, start)
|
|
335
|
+
except json.JSONDecodeError:
|
|
336
|
+
return False, None
|
|
337
|
+
except RecursionError:
|
|
338
|
+
# A pathologically nested span overflows the JSON decoder's recursion guard;
|
|
339
|
+
# treat it as a malformed span (skipped as a whole) rather than letting an
|
|
340
|
+
# uncaught RecursionError escape the parser's stable-ValueError contract. Kept as
|
|
341
|
+
# its own clause (not ``except (JSONDecodeError, RecursionError):``) because
|
|
342
|
+
# ruff-format rewrites that tuple into the invalid Py2 ``except A, B:`` syntax.
|
|
343
|
+
return False, None
|
|
344
|
+
return (True, value) if decode_end == end + 1 else (False, None)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _extract_top_level_objects(cleaned: str) -> dict[str, Any]:
|
|
348
|
+
"""Scan for balanced top-level containers and select the single usable object.
|
|
349
|
+
|
|
350
|
+
Recovery path when the whole response is not itself one JSON object. Scans
|
|
351
|
+
left-to-right; each top-level ``{``/``[`` opener is matched to its closer as one
|
|
352
|
+
indivisible span (:func:`_match_container_span`) and decoded as a whole
|
|
353
|
+
(:func:`_decode_span`). Nested openers are never re-scanned and arrays are skipped
|
|
354
|
+
as containers, so an illustrative or nested object is never promoted.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
cleaned: The cleaned response text (fences stripped, whitespace trimmed).
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
The single top-level object that carries a non-blank ``description``.
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
ValueError: ``Improver returned ambiguous JSON: multiple usable description
|
|
364
|
+
objects`` if two or more usable objects are found; ``Improver returned
|
|
365
|
+
invalid JSON`` on a structural failure or a malformed/non-object span with
|
|
366
|
+
no usable object; ``Improver JSON missing 'description'`` when only complete
|
|
367
|
+
description-less objects are found; ``Improver returned no JSON`` when no
|
|
368
|
+
container is present.
|
|
369
|
+
"""
|
|
370
|
+
usable: list[dict[str, Any]] = []
|
|
371
|
+
saw_invalid = False
|
|
372
|
+
saw_dict_without_desc = False
|
|
373
|
+
index = 0
|
|
374
|
+
while index < len(cleaned):
|
|
375
|
+
char = cleaned[index]
|
|
376
|
+
if char not in "{[":
|
|
377
|
+
index += 1
|
|
378
|
+
continue
|
|
379
|
+
end = _match_container_span(cleaned, index)
|
|
380
|
+
decoded, value = _decode_span(cleaned, index, end)
|
|
381
|
+
if not decoded or not isinstance(value, dict):
|
|
382
|
+
# Malformed span, or a balanced non-object (e.g. an array): skip as a whole.
|
|
383
|
+
saw_invalid = True
|
|
384
|
+
elif _has_description(cast("dict[str, Any]", value)):
|
|
385
|
+
usable.append(cast("dict[str, Any]", value))
|
|
386
|
+
else:
|
|
387
|
+
saw_dict_without_desc = True
|
|
388
|
+
index = end + 1
|
|
389
|
+
if len(usable) >= 2:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
"Improver returned ambiguous JSON: multiple usable description objects"
|
|
392
|
+
)
|
|
393
|
+
if len(usable) == 1:
|
|
394
|
+
return usable[0]
|
|
395
|
+
if saw_invalid:
|
|
396
|
+
raise ValueError("Improver returned invalid JSON")
|
|
397
|
+
if saw_dict_without_desc:
|
|
398
|
+
raise ValueError("Improver JSON missing 'description'")
|
|
399
|
+
raise ValueError("Improver returned no JSON")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _parse_improver_output(raw: str) -> dict[str, Any]:
|
|
403
|
+
"""Extract and validate the JSON object from a raw improver response.
|
|
404
|
+
|
|
405
|
+
Strips code fences, then tries to parse the whole cleaned response as one JSON
|
|
406
|
+
value. A whole-response object must carry a non-blank ``description``; a scalar or
|
|
407
|
+
list root is rejected outright. Only when whole-response parsing fails does the
|
|
408
|
+
string/container-aware recovery scanner (:func:`_extract_top_level_objects`) run.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
raw: The raw ``claude -p`` stdout text.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
The parsed JSON object, guaranteed to contain a non-blank ``description``.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ValueError: With one of the fixed messages ``Improver returned no JSON``,
|
|
418
|
+
``Improver JSON missing 'description'``, ``Improver returned invalid JSON``,
|
|
419
|
+
or ``Improver returned ambiguous JSON: multiple usable description
|
|
420
|
+
objects`` when the response cannot yield exactly one usable object.
|
|
421
|
+
"""
|
|
422
|
+
cleaned = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.MULTILINE).strip()
|
|
423
|
+
try:
|
|
424
|
+
whole = json.loads(cleaned)
|
|
425
|
+
except RecursionError as exc:
|
|
426
|
+
# Pathologically nested JSON overflows the decoder's recursion. Map it to the
|
|
427
|
+
# stable invalid-JSON message (which the caller treats as a retryable
|
|
428
|
+
# ``invalid_output``) instead of letting an uncaught RecursionError escape the
|
|
429
|
+
# parser's ValueError contract and abort the run with a traceback.
|
|
430
|
+
raise ValueError("Improver returned invalid JSON") from exc
|
|
431
|
+
except json.JSONDecodeError:
|
|
432
|
+
return _extract_top_level_objects(cleaned)
|
|
433
|
+
if isinstance(whole, dict):
|
|
434
|
+
data = cast("dict[str, Any]", whole)
|
|
435
|
+
if _has_description(data):
|
|
436
|
+
return data
|
|
437
|
+
raise ValueError("Improver JSON missing 'description'")
|
|
438
|
+
raise ValueError("Improver returned invalid JSON")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _parse_or_retryable(raw: str) -> dict[str, Any]:
|
|
442
|
+
"""Parse the improver output, wrapping only known parser failures as retryable.
|
|
443
|
+
|
|
444
|
+
Known :func:`_parse_improver_output` messages become
|
|
445
|
+
:class:`ImproverRetryableError` (``invalid_output``); any other ``ValueError`` is
|
|
446
|
+
left untouched so configuration/programming faults still propagate fatally.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
raw: The raw ``claude -p`` stdout text.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
The parsed object with a non-blank ``description``.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
ImproverRetryableError: When parsing fails with an allowlisted parser message.
|
|
456
|
+
ValueError: Any other parse failure, propagated unchanged.
|
|
457
|
+
"""
|
|
458
|
+
try:
|
|
459
|
+
return _parse_improver_output(raw)
|
|
460
|
+
except ImproverRetryableError:
|
|
461
|
+
raise
|
|
462
|
+
except ValueError as exc:
|
|
463
|
+
message = str(exc)
|
|
464
|
+
if message in _RETRYABLE_MESSAGES["invalid_output"]:
|
|
465
|
+
raise ImproverRetryableError("invalid_output", message) from exc
|
|
466
|
+
raise
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _write_transcript(log_path: Path | None, transcript: dict[str, Any]) -> None:
|
|
470
|
+
"""Persist the improver transcript, best-effort (never replaces the primary outcome).
|
|
471
|
+
|
|
472
|
+
Ordinary serialization/write errors are swallowed with a fixed warning so a
|
|
473
|
+
transcript-write failure cannot mask a success, retry, or fatal result.
|
|
474
|
+
``BaseException`` (``KeyboardInterrupt``/``SystemExit``) is not caught.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
log_path: Where to write the JSON transcript, or ``None`` to skip.
|
|
478
|
+
transcript: The transcript mapping to serialize.
|
|
479
|
+
"""
|
|
480
|
+
if log_path is None:
|
|
481
|
+
return
|
|
482
|
+
try:
|
|
483
|
+
log_path.write_text(json.dumps(transcript, indent=2))
|
|
484
|
+
except Exception: # noqa: BLE001 - best-effort; must not replace the primary outcome
|
|
485
|
+
logger.warning("Improver transcript write failed.")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _run_improver_subprocess(
|
|
489
|
+
prompt: str, model: str, effort: str | None, timeout: int, budget: _LaunchBudget
|
|
490
|
+
) -> subprocess.CompletedProcess[str]:
|
|
491
|
+
"""Run one ``claude -p`` improver subprocess and return the completed process.
|
|
492
|
+
|
|
493
|
+
Consumes one launch token from ``budget`` as its first action, before spawning, so
|
|
494
|
+
the launch ceiling cannot be bypassed. Kept separate from parsing so the raw stdout
|
|
495
|
+
is available for the transcript before the return-code check or JSON parse can
|
|
496
|
+
raise. The prompt is sent over stdin (not argv): it embeds the full SKILL.md body
|
|
497
|
+
and prior attempts, which can exceed a comfortable argv length.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
prompt: The prompt to send on stdin.
|
|
501
|
+
model: Model id for the improver.
|
|
502
|
+
effort: Reasoning effort, or ``None`` to omit ``--effort``.
|
|
503
|
+
timeout: Subprocess timeout, in seconds.
|
|
504
|
+
budget: The launch budget; one token is consumed before spawning.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
The completed ``claude -p`` process, with stdout/stderr captured as text.
|
|
508
|
+
|
|
509
|
+
Raises:
|
|
510
|
+
ImproverFatalProcessError: If the launch budget is already exhausted.
|
|
511
|
+
"""
|
|
512
|
+
budget.consume()
|
|
513
|
+
cmd = [claude_bin(), "-p", "--model", model, "--output-format", "text"]
|
|
514
|
+
if effort:
|
|
515
|
+
cmd += ["--effort", effort]
|
|
516
|
+
# cmd is a fixed argv list built from internal constants and CLI-provided
|
|
517
|
+
# model/effort strings, never a shell string -- no shell=True, no injection
|
|
518
|
+
# surface. The prompt travels over stdin, not argv.
|
|
519
|
+
return subprocess.run( # noqa: S603 # nosec B603
|
|
520
|
+
cmd,
|
|
521
|
+
input=prompt,
|
|
522
|
+
capture_output=True,
|
|
523
|
+
text=True,
|
|
524
|
+
env=subprocess_env(),
|
|
525
|
+
timeout=timeout,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def call_improver(
|
|
530
|
+
prompt: str,
|
|
531
|
+
model: str,
|
|
532
|
+
effort: str | None,
|
|
533
|
+
timeout: int,
|
|
534
|
+
max_chars: int = DESCRIPTION_CHAR_LIMIT,
|
|
535
|
+
log_path: Path | None = None,
|
|
536
|
+
budget: _LaunchBudget | None = None,
|
|
537
|
+
) -> dict[str, Any]:
|
|
538
|
+
"""Ask the improver for a rewritten description, enforcing the length limit.
|
|
539
|
+
|
|
540
|
+
If the first proposal exceeds ``max_chars``, makes one further ``claude -p`` call
|
|
541
|
+
that quotes the over-long text and asks for a shorter rewrite. When ``log_path`` is
|
|
542
|
+
set, a JSON transcript (prompt, raw response + return code + stderr, parsed
|
|
543
|
+
description, char count, and any rewrite round) is written there, best-effort. When
|
|
544
|
+
no ``budget`` is injected, a local two-launch budget bounds this call to the initial
|
|
545
|
+
child plus at most one shortening child.
|
|
546
|
+
|
|
547
|
+
Failure classification: a subprocess timeout, a known parser failure, and a
|
|
548
|
+
still-over-limit description after shortening are typed
|
|
549
|
+
:class:`ImproverRetryableError`; a completed nonzero exit and launch-budget
|
|
550
|
+
exhaustion are :class:`ImproverFatalProcessError`; every other error (missing
|
|
551
|
+
executable, permissions, configuration, programming faults) propagates unchanged.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
prompt: The improver prompt from :func:`build_improver_prompt`.
|
|
555
|
+
model: Model id for the improver.
|
|
556
|
+
effort: Reasoning effort (``high``/``medium``/``low``), or ``None`` to omit.
|
|
557
|
+
timeout: Subprocess timeout, in seconds.
|
|
558
|
+
max_chars: Hard character ceiling for the description (default 1024).
|
|
559
|
+
log_path: Where to write the JSON transcript, or ``None`` to skip logging.
|
|
560
|
+
budget: Shared launch budget, or ``None`` to use a local two-launch budget.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
The parsed JSON object, guaranteed to contain a non-empty ``description``.
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
ImproverRetryableError: On a timeout, a known parser failure, or a description
|
|
567
|
+
still over ``max_chars`` after the shorten retry.
|
|
568
|
+
ImproverFatalProcessError: On a completed nonzero child exit or launch-budget
|
|
569
|
+
exhaustion.
|
|
570
|
+
"""
|
|
571
|
+
if budget is None:
|
|
572
|
+
# A standalone call: enough for the initial child and at most one shortening
|
|
573
|
+
# child. A third child in the same call is refused.
|
|
574
|
+
budget = _LaunchBudget(2)
|
|
575
|
+
# Build the transcript incrementally so a first-call failure (non-zero exit or a
|
|
576
|
+
# malformed response) still leaves the raw output behind for diagnosis, rather than
|
|
577
|
+
# raising before anything is written.
|
|
578
|
+
transcript: dict[str, Any] = {"prompt": prompt}
|
|
579
|
+
try:
|
|
580
|
+
result = _run_improver_subprocess(prompt, model, effort, timeout, budget)
|
|
581
|
+
transcript["response"] = result.stdout.strip()
|
|
582
|
+
transcript["returncode"] = result.returncode
|
|
583
|
+
transcript["stderr"] = result.stderr
|
|
584
|
+
if result.returncode != 0:
|
|
585
|
+
raise ImproverFatalProcessError(
|
|
586
|
+
f"Improver process exited with status {result.returncode}"
|
|
587
|
+
)
|
|
588
|
+
data = _parse_or_retryable(result.stdout.strip())
|
|
589
|
+
description = str(data["description"]).strip()
|
|
590
|
+
transcript["parsed_description"] = description
|
|
591
|
+
transcript["char_count"] = len(description)
|
|
592
|
+
transcript["over_limit"] = len(description) > max_chars
|
|
593
|
+
if len(description) > max_chars:
|
|
594
|
+
shorten_prompt = prompt + (
|
|
595
|
+
"\n\n---\n\n"
|
|
596
|
+
f"A previous attempt produced this description, which at "
|
|
597
|
+
f"{len(description)} characters is over the {max_chars}-"
|
|
598
|
+
f"character hard limit:\n\n"
|
|
599
|
+
f'"{description}"\n\n'
|
|
600
|
+
f"Rewrite it to be under {max_chars} characters while "
|
|
601
|
+
"keeping the most important trigger words and intent coverage. Return "
|
|
602
|
+
'ONLY a JSON object like {"description": "...", "rationale": "..."}, '
|
|
603
|
+
"no prose, no code fences."
|
|
604
|
+
)
|
|
605
|
+
# Record retry inputs and stdout before either the process status or JSON
|
|
606
|
+
# parsing can fail. Failed retries are otherwise exactly the cases where
|
|
607
|
+
# the transcript is most useful for diagnosis.
|
|
608
|
+
transcript["rewrite_prompt"] = shorten_prompt
|
|
609
|
+
rewrite_result = _run_improver_subprocess(
|
|
610
|
+
shorten_prompt, model, effort, timeout, budget
|
|
611
|
+
)
|
|
612
|
+
transcript["rewrite_response"] = rewrite_result.stdout.strip()
|
|
613
|
+
transcript["rewrite_returncode"] = rewrite_result.returncode
|
|
614
|
+
transcript["rewrite_stderr"] = rewrite_result.stderr
|
|
615
|
+
if rewrite_result.returncode != 0:
|
|
616
|
+
raise ImproverFatalProcessError(
|
|
617
|
+
f"Improver process exited with status {rewrite_result.returncode}"
|
|
618
|
+
)
|
|
619
|
+
data = _parse_or_retryable(rewrite_result.stdout.strip())
|
|
620
|
+
shortened = str(data["description"]).strip()
|
|
621
|
+
transcript["rewrite_char_count"] = len(shortened)
|
|
622
|
+
if len(shortened) > max_chars:
|
|
623
|
+
raise ImproverRetryableError(
|
|
624
|
+
"length_limit",
|
|
625
|
+
"Improver description exceeded the configured character limit "
|
|
626
|
+
"after shortening",
|
|
627
|
+
)
|
|
628
|
+
return data
|
|
629
|
+
except subprocess.TimeoutExpired as exc:
|
|
630
|
+
transcript.setdefault("error", repr(exc))
|
|
631
|
+
raise ImproverRetryableError("timeout", "Improver timed out") from exc
|
|
632
|
+
except Exception as exc: # noqa: BLE001 - re-raised; only records a diagnostic note
|
|
633
|
+
transcript.setdefault("error", repr(exc))
|
|
634
|
+
raise
|
|
635
|
+
finally:
|
|
636
|
+
_write_transcript(log_path, transcript)
|