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,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)