acumen 0.0.1.dev0__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.
acumen/grade.py ADDED
@@ -0,0 +1,104 @@
1
+ """Exact-match grader.
2
+
3
+ Grading is exact string comparison of ``answer.md`` after :meth:`str.strip`, case
4
+ sensitive. Nothing is normalized away before comparing — a run that would only pass
5
+ under normalization is still a failure, but it is recorded as ``format_error`` rather
6
+ than ``wrong_answer`` so the report can show format noise separately from genuinely
7
+ wrong answers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ from acumen.paths import ANSWER_FILE
18
+
19
+ Reason = Literal["ok", "wrong_answer", "format_error", "no_answer_file", "budget", "max_turns", "error"]
20
+
21
+ _FENCE_RE = re.compile(r"^\s*```[^\n]*\n(?P<body>.*?)\n?\s*```\s*$", re.DOTALL)
22
+ _HEADER_LINE_RE = re.compile(r"^\s*#+\s")
23
+ _LABEL_RE = re.compile(
24
+ r"^(?:the\s+)?(?:final\s+)?answer(?:\s+is)?\s*[:=]\s*|^(?:the\s+)?(?:final\s+)?answer\s+is\s+",
25
+ re.IGNORECASE,
26
+ )
27
+ _WRAPPERS = ("**", "__", "`", "*", "_")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Grade:
32
+ """The outcome of grading one run."""
33
+
34
+ success: bool
35
+ reason: Reason
36
+ answer: str | None
37
+
38
+
39
+ def _loosen(text: str) -> str:
40
+ """Strip the formatting the harness prompt forbids, for format-noise detection only.
41
+
42
+ This never makes a run pass — it only tells ``wrong_answer`` apart from an answer
43
+ whose content was right but whose formatting broke the exact match.
44
+ """
45
+ out = text.strip()
46
+ fenced = _FENCE_RE.match(out)
47
+ if fenced:
48
+ out = fenced.group("body").strip()
49
+
50
+ lines = [line for line in out.splitlines() if line.strip()]
51
+ # A markdown header above the answer ("# Answer\nSPI1") is formatting, not content.
52
+ body = [line for line in lines if not _HEADER_LINE_RE.match(line)] or lines
53
+ if len(body) == 1:
54
+ out = body[0].strip()
55
+
56
+ out = _LABEL_RE.sub("", out).strip()
57
+
58
+ # Emphasis and quoting can nest — `**"SPI1"**` — so peel until stable.
59
+ while True:
60
+ before = out
61
+ for pair in _WRAPPERS:
62
+ if len(out) > 2 * len(pair) and out.startswith(pair) and out.endswith(pair):
63
+ out = out[len(pair) : -len(pair)].strip()
64
+ if len(out) >= 2 and out[0] == out[-1] and out[0] in "\"'":
65
+ out = out[1:-1].strip()
66
+ if out == before:
67
+ break
68
+
69
+ return out.rstrip(".").strip()
70
+
71
+
72
+ def grade_answer(answer: str | None, expected: str) -> Grade:
73
+ """Grade a raw ``answer.md`` body against the expected answer.
74
+
75
+ Parameters
76
+ ----------
77
+ answer
78
+ The file's contents, or ``None`` if the agent never wrote it.
79
+ expected
80
+ The ground-truth answer from ``tasks.yaml``.
81
+
82
+ Returns
83
+ -------
84
+ The grade, whose ``reason`` distinguishes format noise from a wrong answer.
85
+ """
86
+ if answer is None:
87
+ return Grade(success=False, reason="no_answer_file", answer=None)
88
+ stripped = answer.strip()
89
+ want = expected.strip()
90
+ if stripped == want:
91
+ return Grade(success=True, reason="ok", answer=stripped)
92
+ if stripped and _loosen(stripped) == _loosen(want):
93
+ return Grade(success=False, reason="format_error", answer=stripped)
94
+ return Grade(success=False, reason="wrong_answer", answer=stripped)
95
+
96
+
97
+ def grade_run(run_dir: Path, expected: str) -> Grade:
98
+ """Grade a run directory by reading its ``answer.md``."""
99
+ path = run_dir / ANSWER_FILE
100
+ try:
101
+ answer = path.read_text()
102
+ except OSError:
103
+ return Grade(success=False, reason="no_answer_file", answer=None)
104
+ return grade_answer(answer, expected)
acumen/improve.py ADDED
@@ -0,0 +1,596 @@
1
+ """The improving agent: read train evidence, write ``skills/v{n+1}/``.
2
+
3
+ Where the drafter reads the package *source* to write the first skill, the improver
4
+ reads how the current skill *performed* — on the TRAIN split only — and edits it. Versions
5
+ are immutable: ``improve`` always writes a new directory, never mutates an existing one.
6
+
7
+ The load-bearing constraint is that the improver must never see test results, or the whole
8
+ benchmark is worthless. This is enforced two ways, not one:
9
+
10
+ 1. **Structurally.** The agent's ``cwd`` is a throwaway work dir and its readable material
11
+ is a *curated copy* of train-split runs. The real ``runs/`` tree is not on its ``cwd``,
12
+ not in ``add_dirs``, and not in scope — so there is no test directory to reach in the
13
+ first place.
14
+ 2. **Belt-and-braces.** A ``PreToolUse`` hook denies any tool call whose path resolves under
15
+ a ``runs/*/test/`` subtree, so even a call that names an absolute path back into the
16
+ project runs tree is refused. See :func:`find_test_access`.
17
+
18
+ Prompt-level instruction alone is explicitly *not* sufficient.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import shutil
25
+ import tempfile
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from claude_agent_sdk import ClaudeAgentOptions, HookMatcher, ResultMessage, query
31
+
32
+ from acumen.config import Config
33
+ from acumen.env import AuthMode, Target, build_agent_env
34
+ from acumen.logs import LiveLog
35
+ from acumen.paths import (
36
+ ANSWER_FILE,
37
+ RESULT_FILE,
38
+ SCRIPT_FILE,
39
+ TRANSCRIPT_HTML,
40
+ TRANSCRIPT_JSONL,
41
+ arm_name,
42
+ parse_run_dir,
43
+ )
44
+ from acumen.procs import label_env, reap
45
+ from acumen.prompts import improve_prompt
46
+ from acumen.skills import (
47
+ SKILL_FILE,
48
+ Skill,
49
+ SkillError,
50
+ content_files,
51
+ load_skill,
52
+ next_version,
53
+ skill_dir,
54
+ write_meta,
55
+ )
56
+ from acumen.tasks import Task
57
+
58
+ #: The subtree component that marks a held-out split. A path under ``runs/<arm>/test/…`` is
59
+ #: what the improver must never reach.
60
+ _TEST_SPLIT = "test"
61
+
62
+ #: tool_input keys that carry a filesystem path across the default Claude Code toolset.
63
+ _PATH_KEYS = ("file_path", "path", "notebook_path", "filename")
64
+
65
+ #: Shell metacharacters we split a Bash command on to recover path-like tokens. Coarse on
66
+ #: purpose — the structural isolation is the real guarantee; this only has to catch an
67
+ #: absolute path named directly in a command.
68
+ _SHELL_SPLIT = str.maketrans(dict.fromkeys("\"'`|&;<>()$" + "{}", " "))
69
+
70
+
71
+ class ImproveError(RuntimeError):
72
+ """Raised when a skill could not be improved."""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class TrainRun:
77
+ """One train-split benchmark run of the parent skill, as evidence for the improver.
78
+
79
+ ``success`` and ``skill_loaded`` are independent outcomes with independent fixes: a run
80
+ that never loaded the skill says nothing about the skill's body, only about its
81
+ ``description``. Both reach the improver so it can tell the two apart.
82
+ """
83
+
84
+ task_id: str
85
+ model: str
86
+ rep: int
87
+ prompt: str
88
+ expected: str
89
+ answer: str | None
90
+ reason: str
91
+ success: bool
92
+ directory: Path
93
+ #: Whether the agent actually invoked the skill. ``None`` when the transcript could not
94
+ #: be read — undetermined, which is not the same as "did not load".
95
+ skill_loaded: bool | None = None
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class ImproveResult:
100
+ """A newly improved skill version and what it cost to produce."""
101
+
102
+ skill: Skill
103
+ parent: str
104
+ cost_usd: float
105
+ turns: int
106
+ n_train_runs: int
107
+ n_train_failures: int
108
+ #: Live log paths for this run, when a :class:`LiveLog` was attached.
109
+ log_jsonl: Path | None = None
110
+ log_html: Path | None = None
111
+
112
+
113
+ # ── Train evidence ─────────────────────────────────────────────────────────────────────
114
+
115
+
116
+ def collect_train_runs(runs_root: Path, arm: str, tasks: list[Task]) -> list[TrainRun]:
117
+ """Collect the parent arm's train-split runs into evidence for the improver.
118
+
119
+ Walks ``runs_root/<arm>/train`` for ``result.json`` files and pairs each with its task's
120
+ train prompt and ground-truth answer. Only train runs are ever collected — the test
121
+ subtree is never even visited.
122
+
123
+ Parameters
124
+ ----------
125
+ runs_root
126
+ The ``runs/`` root.
127
+ arm
128
+ The arm whose train runs are the improvement signal, e.g. ``"skill_v1"``.
129
+ tasks
130
+ The loaded tasks, to recover each run's prompt and expected answer.
131
+
132
+ Returns
133
+ -------
134
+ The train runs, failures first then by task/rep — so a reader sees what went wrong up
135
+ front.
136
+ """
137
+ train_root = runs_root / arm / "train"
138
+ by_id = {task.id: task for task in tasks}
139
+ runs: list[TrainRun] = []
140
+ if not train_root.is_dir():
141
+ return runs
142
+ for result_path in sorted(train_root.rglob(RESULT_FILE)):
143
+ try:
144
+ data = json.loads(result_path.read_text())
145
+ except (OSError, ValueError) as err:
146
+ raise ImproveError(f"cannot read {result_path}: {err}") from err
147
+ key = parse_run_dir(runs_root, result_path.parent)
148
+ task = by_id.get(key.task_id)
149
+ if task is None:
150
+ # A run for a task no longer in tasks.yaml is stale evidence; skip it rather
151
+ # than guess a prompt for it.
152
+ continue
153
+ runs.append(
154
+ TrainRun(
155
+ task_id=key.task_id,
156
+ model=str(data.get("model", key.model)),
157
+ rep=key.rep,
158
+ prompt=task.train.prompt.strip(),
159
+ expected=task.train.answer.strip(),
160
+ answer=data.get("answer"),
161
+ reason=str(data.get("reason", "")),
162
+ success=bool(data.get("success", False)),
163
+ directory=result_path.parent,
164
+ skill_loaded=_loaded(data.get("skill_loaded")),
165
+ )
166
+ )
167
+ runs.sort(key=lambda r: (r.success, r.task_id, r.model, r.rep))
168
+ return runs
169
+
170
+
171
+ def _loaded(value: Any) -> bool | None:
172
+ """Coerce a ``result.json`` ``skill_loaded`` field, preserving "undetermined" as ``None``."""
173
+ return None if value is None else bool(value)
174
+
175
+
176
+ def load_rates(runs: list[TrainRun]) -> dict[str, tuple[int, int, int]]:
177
+ """Count how often the skill actually loaded, per model.
178
+
179
+ Reported per model rather than pooled because the load rate varies far more across models
180
+ than across skill versions — a pooled number invites the improver to chase a model whose
181
+ behaviour no wording can reach.
182
+
183
+ Parameters
184
+ ----------
185
+ runs
186
+ The train runs to tally.
187
+
188
+ Returns
189
+ -------
190
+ ``{model: (loaded, total, undetermined)}``, where ``undetermined`` counts runs whose
191
+ transcript could not be read and which are therefore excluded from ``loaded``.
192
+ """
193
+ rates: dict[str, tuple[int, int, int]] = {}
194
+ for run in runs:
195
+ loaded, total, unknown = rates.get(run.model, (0, 0, 0))
196
+ rates[run.model] = (
197
+ loaded + (run.skill_loaded is True),
198
+ total + 1,
199
+ unknown + (run.skill_loaded is None),
200
+ )
201
+ return dict(sorted(rates.items()))
202
+
203
+
204
+ def _load_mark(loaded: bool | None) -> str:
205
+ """The phrase naming a run's load outcome, used identically in SUMMARY.md and run.md."""
206
+ if loaded is None:
207
+ return "skill load UNDETERMINED"
208
+ return "skill LOADED" if loaded else "skill NOT LOADED"
209
+
210
+
211
+ def _load_section(runs: list[TrainRun]) -> list[str]:
212
+ """The SUMMARY.md section reporting per-model load rates."""
213
+ rates = load_rates(runs)
214
+ lines = [
215
+ "## Did the skill load at all?",
216
+ "",
217
+ "A run where the skill never loaded is not evidence about the skill's body — the agent",
218
+ "never read it. Only the `description` in the frontmatter decides whether a skill loads.",
219
+ "",
220
+ "| model | loaded | runs | rate |",
221
+ "| --- | ---: | ---: | ---: |",
222
+ ]
223
+ total_loaded = total_runs = total_unknown = 0
224
+ for model, (loaded, total, unknown) in rates.items():
225
+ total_loaded += loaded
226
+ total_runs += total
227
+ total_unknown += unknown
228
+ lines.append(f"| `{model}` | {loaded} | {total} | {loaded / total:.0%} |")
229
+ if total_runs:
230
+ lines.extend(["", f"**Overall: {total_loaded}/{total_runs} ({total_loaded / total_runs:.0%}).**"])
231
+ if total_unknown:
232
+ noun = "run" if total_unknown == 1 else "runs"
233
+ lines.append(
234
+ f"({total_unknown} {noun} had no readable transcript, so loading is undetermined and is "
235
+ "counted as not loaded above.)"
236
+ )
237
+ lines.append("")
238
+ return lines
239
+
240
+
241
+ def _write_material(train_dir: Path, runs: list[TrainRun]) -> None:
242
+ """Lay out the curated train evidence the improver reads.
243
+
244
+ A ``SUMMARY.md`` digest (per-model load rates, then runs with failures first) plus one
245
+ directory per run holding the run's ``script.py``, transcript, and a ``run.md`` stating
246
+ prompt/expected/actual/outcome and whether the skill loaded. Copied, not linked, so the
247
+ agent never has a path back into the real runs tree.
248
+ """
249
+ train_dir.mkdir(parents=True, exist_ok=True)
250
+ n_fail = sum(1 for r in runs if not r.success)
251
+ noun = "run" if len(runs) == 1 else "runs"
252
+ lines = [
253
+ "# Train-split evidence for the current skill",
254
+ "",
255
+ "Each run below benchmarked the current skill on one train task. Two things were",
256
+ "recorded per run, and they fail for different reasons and take different fixes:",
257
+ "whether the agent got the answer RIGHT, and whether the agent LOADED the skill at",
258
+ "all. Read the load rates first, then the failing runs. Open a run's `run.md`,",
259
+ "`script.py`, and `transcript.html` for the detail.",
260
+ "",
261
+ f"**{len(runs)} {noun}, {n_fail} failing.**",
262
+ "",
263
+ *_load_section(runs),
264
+ "## Runs",
265
+ "",
266
+ ]
267
+ for run in runs:
268
+ slug = f"{run.task_id}__{run.model}__rep_{run.rep}"
269
+ mark = "PASS" if run.success else "FAIL"
270
+ lines.append(
271
+ f"- `{slug}/` — **{mark}** ({run.reason}) — {_load_mark(run.skill_loaded)} — "
272
+ f"task `{run.task_id}` on `{run.model}` — expected `{run.expected}`, got `{run.answer!r}`"
273
+ )
274
+ run_out = train_dir / slug
275
+ run_out.mkdir(parents=True, exist_ok=True)
276
+ (run_out / "run.md").write_text(
277
+ f"# {run.task_id} — {mark} ({run.reason})\n\n"
278
+ f"- Model: `{run.model}`\n"
279
+ f"- Skill: {_load_mark(run.skill_loaded)}\n\n"
280
+ f"## Task prompt\n\n{run.prompt}\n\n"
281
+ f"## Expected answer\n\n{run.expected}\n\n"
282
+ f"## Answer the agent gave\n\n{run.answer!r}\n\n"
283
+ f"## Files\n\n- `script.py` — the script the agent wrote\n"
284
+ f"- `transcript.html` — the full run transcript\n"
285
+ )
286
+ for name in (SCRIPT_FILE, ANSWER_FILE, TRANSCRIPT_HTML, TRANSCRIPT_JSONL):
287
+ src = run.directory / name
288
+ if src.is_file():
289
+ shutil.copyfile(src, run_out / name)
290
+ (train_dir / "SUMMARY.md").write_text("\n".join(lines) + "\n")
291
+
292
+
293
+ # ── Test-access guard ──────────────────────────────────────────────────────────────────
294
+
295
+
296
+ def _blocks(candidate: str, runs_root: Path) -> bool:
297
+ """Return whether ``candidate`` resolves under a ``runs/*/test/`` subtree."""
298
+ try:
299
+ resolved = Path(candidate).expanduser().resolve()
300
+ except (OSError, RuntimeError, ValueError):
301
+ return False
302
+ try:
303
+ rel = resolved.relative_to(runs_root)
304
+ except ValueError:
305
+ return False
306
+ parts = rel.parts
307
+ # runs_root/<arm>/test/... — the split is the second component.
308
+ return len(parts) >= 2 and parts[1] == _TEST_SPLIT
309
+
310
+
311
+ def find_test_access(tool_name: str, tool_input: dict[str, Any], runs_root: Path) -> str | None:
312
+ """Return the first path in a tool call that reaches held-out test results, else ``None``.
313
+
314
+ Pure and side-effect free, so the enforcement can be exercised directly without
315
+ standing up an agent. Checks the path-bearing tool_input keys, and — for shell tools —
316
+ the whitespace/metacharacter-split tokens of the command, since a Bash call can name a
317
+ path no structured field would.
318
+
319
+ Parameters
320
+ ----------
321
+ tool_name
322
+ The tool being invoked; unused today but kept so the guard can special-case tools.
323
+ tool_input
324
+ The tool's arguments.
325
+ runs_root
326
+ The project ``runs/`` root, resolved by the caller.
327
+
328
+ Returns
329
+ -------
330
+ The offending path string, or ``None`` if the call touches no test results.
331
+ """
332
+ for key in _PATH_KEYS:
333
+ value = tool_input.get(key)
334
+ if isinstance(value, str) and _blocks(value, runs_root):
335
+ return value
336
+ command = tool_input.get("command")
337
+ if isinstance(command, str):
338
+ for token in command.translate(_SHELL_SPLIT).split():
339
+ token = token.rstrip(",;")
340
+ if ("runs" in token or token.startswith("/") or token.startswith("~")) and _blocks(token, runs_root):
341
+ return token
342
+ return None
343
+
344
+
345
+ def make_test_guard(runs_root: Path) -> HookMatcher:
346
+ """Build the ``PreToolUse`` hook that denies any access to held-out test results.
347
+
348
+ ``matcher=None`` fires the hook for every tool. The hook resolves paths against the real
349
+ project ``runs/`` root, so it holds regardless of the agent's ``cwd``.
350
+ """
351
+ root = runs_root.resolve()
352
+
353
+ async def guard(input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
354
+ hit = find_test_access(
355
+ input_data.get("tool_name", ""),
356
+ input_data.get("tool_input", {}) or {},
357
+ root,
358
+ )
359
+ if hit is None:
360
+ return {}
361
+ return {
362
+ "hookSpecificOutput": {
363
+ "hookEventName": "PreToolUse",
364
+ "permissionDecision": "deny",
365
+ "permissionDecisionReason": (
366
+ f"acumen blocks the improver from reading held-out test results ({hit}). "
367
+ "Only train-split evidence is available to you."
368
+ ),
369
+ }
370
+ }
371
+
372
+ return HookMatcher(matcher=None, hooks=[guard])
373
+
374
+
375
+ # ── Orchestration ──────────────────────────────────────────────────────────────────────
376
+
377
+
378
+ def _seed_staging(staging: Path, parent: Skill) -> None:
379
+ """Pre-fill the staging dir with the parent skill's content, so the agent edits in place.
380
+
381
+ Only content files are copied — ``meta.json`` is acumen bookkeeping and is written
382
+ fresh once the new version is promoted.
383
+ """
384
+ staging.mkdir(parents=True, exist_ok=True)
385
+ for src in content_files(parent.directory):
386
+ dest = staging / src.relative_to(parent.directory)
387
+ dest.parent.mkdir(parents=True, exist_ok=True)
388
+ shutil.copyfile(src, dest)
389
+
390
+
391
+ def _validate_staged(staging: Path, skill_name: str) -> None:
392
+ """Fail loudly if the agent's output isn't a usable skill, before it becomes a version."""
393
+ if not (staging / SKILL_FILE).is_file():
394
+ raise ImproveError(
395
+ f"the improving agent left no {SKILL_FILE} in the staging directory — nothing to "
396
+ "promote. Inspect the run log or the prompt."
397
+ )
398
+ try:
399
+ load_skill(staging.parent, staging.name, expect_name=skill_name)
400
+ except SkillError as err:
401
+ raise ImproveError(f"the improved skill is not valid: {err}") from err
402
+
403
+
404
+ async def improve_skill(
405
+ *,
406
+ cfg: Config,
407
+ target: Target,
408
+ skills_root: Path,
409
+ runs_root: Path,
410
+ tasks: list[Task],
411
+ auth_mode: AuthMode = "session",
412
+ parent_version: str | None = None,
413
+ model: str | None = None,
414
+ max_turns: int | None = None,
415
+ max_usd: float | None = None,
416
+ feedback: str | None = None,
417
+ log: LiveLog | None = None,
418
+ ) -> ImproveResult:
419
+ """Improve the current skill into the next version from its train-split evidence.
420
+
421
+ Parameters
422
+ ----------
423
+ cfg
424
+ The pass config; supplies ``skill_name`` and ``improve_model``.
425
+ target
426
+ The prepared target, supplying the interpreter (for verifying claims). Its source
427
+ is **not** exposed — the improver works from evidence, not code.
428
+ skills_root
429
+ The ``skills/`` root; the new version is the next unused one.
430
+ runs_root
431
+ The ``runs/`` root, read for train evidence and guarded against test access.
432
+ tasks
433
+ The loaded tasks, to pair runs with their prompts and expected answers.
434
+ auth_mode
435
+ Which credential the improving agent authenticates with — ``"session"`` (the Claude
436
+ subscription) or ``"api"`` (see :func:`acumen.env.build_agent_env`).
437
+ parent_version
438
+ The version to improve; defaults to the latest present.
439
+ model
440
+ Override for the improving model; defaults to the config's.
441
+ max_turns, max_usd
442
+ Turn and budget caps for the improving agent. **Unbounded by default** — the config's
443
+ ``max_turns``/``max_usd`` cap benchmark agents only; pass explicit caps to bound the
444
+ improver.
445
+ feedback
446
+ Optional maintainer guidance, injected into the improve prompt as a subordinated block
447
+ and recorded in ``meta.json`` as provenance. It is prompt text only — it cannot reach
448
+ the held-out test split, which stays blocked structurally and by the guard hook.
449
+ log
450
+ A :class:`LiveLog` to stream the agent's messages to and render an HTML log from.
451
+
452
+ Returns
453
+ -------
454
+ The improved skill, loaded and validated, with its parent and cost.
455
+ """
456
+ parent = load_skill(skills_root, parent_version or _latest_or_error(skills_root), expect_name=cfg.skill_name)
457
+ new_version = next_version(skills_root)
458
+ dest = skill_dir(skills_root, new_version)
459
+ if dest.exists():
460
+ raise ImproveError(f"{dest} already exists — skill versions are immutable and never overwritten")
461
+
462
+ arm = arm_name(parent.version)
463
+ train_runs = collect_train_runs(runs_root, arm, tasks)
464
+ if not train_runs:
465
+ raise ImproveError(
466
+ f"no train-split runs found for {parent.version} under {runs_root / arm / 'train'} — "
467
+ f"run `acumen bench --skill {parent.version}` first so the improver has evidence to work from"
468
+ )
469
+ n_failures = sum(1 for r in train_runs if not r.success)
470
+
471
+ holder = Path(tempfile.mkdtemp(prefix="acumen-improve-"))
472
+ try:
473
+ work = holder / "work"
474
+ staging = work / new_version
475
+ train_dir = work / "train"
476
+ rationale_path = work / "rationale.md"
477
+ home = holder / "home"
478
+ config_dir = home / ".claude"
479
+ for path in (work, home, config_dir, home / "tmp"):
480
+ path.mkdir(parents=True, exist_ok=True)
481
+
482
+ _seed_staging(staging, parent)
483
+ _write_material(train_dir, train_runs)
484
+
485
+ # Marks the agent's processes so the teardown below can find what it leaves running.
486
+ env = label_env(
487
+ build_agent_env(
488
+ config_dir=config_dir,
489
+ home=home,
490
+ extra_path=[target.bin_dir],
491
+ auth_mode=auth_mode,
492
+ extra_allow=cfg.env_passthrough,
493
+ ),
494
+ holder,
495
+ )
496
+
497
+ prompt = improve_prompt(
498
+ package=target.pkg_name,
499
+ version=target.pkg_version,
500
+ python=target.python,
501
+ skill_dir=staging,
502
+ train_dir=train_dir,
503
+ rationale_path=rationale_path,
504
+ skill_name=cfg.skill_name,
505
+ parent_version=parent.version,
506
+ new_version=new_version,
507
+ feedback=feedback,
508
+ )
509
+ options = ClaudeAgentOptions(
510
+ cwd=str(work),
511
+ env=env,
512
+ model=model or cfg.improve_model,
513
+ # No default turn or budget cap: only bound the agent if the caller asked. The
514
+ # config's ``max_turns``/``max_usd`` cap benchmark agents only.
515
+ max_turns=max_turns,
516
+ max_budget_usd=max_usd,
517
+ setting_sources=["project"],
518
+ permission_mode="bypassPermissions",
519
+ system_prompt={"type": "preset", "preset": "claude_code"},
520
+ # Belt-and-braces over the structural isolation: refuse any call that reaches a
521
+ # held-out test result, wherever the agent points it.
522
+ hooks={"PreToolUse": [make_test_guard(runs_root)]},
523
+ )
524
+
525
+ result: ResultMessage | None = None
526
+ agent_error: Exception | None = None
527
+ try:
528
+ async for message in query(prompt=prompt, options=options):
529
+ if log is not None:
530
+ log.append(message)
531
+ if isinstance(message, ResultMessage):
532
+ result = message
533
+ except Exception as err: # noqa: BLE001 - a failed improve is an error to report, re-raised below
534
+ agent_error = err
535
+ finally:
536
+ # Render the HTML log while the throwaway config dir still holds the native
537
+ # transcript — in a finally so an aborted run (the SDK raises on a cap breach,
538
+ # after yielding the result) is still inspectable.
539
+ if log is not None:
540
+ log.finalize(config_dir=config_dir, work_dir=work, result=result)
541
+
542
+ if agent_error is not None:
543
+ raise ImproveError(
544
+ f"the improving agent failed: {type(agent_error).__name__}: {agent_error}"
545
+ ) from agent_error
546
+ if result is None:
547
+ raise ImproveError("the improving agent produced no result message")
548
+ if result.is_error:
549
+ raise ImproveError(f"the improving agent errored: {result.subtype} {result.errors or ''}".strip())
550
+
551
+ _validate_staged(staging, cfg.skill_name)
552
+
553
+ rationale = _read_rationale(rationale_path, result, parent.version, new_version)
554
+ dest.parent.mkdir(parents=True, exist_ok=True)
555
+ shutil.copytree(staging, dest)
556
+ write_meta(dest, parent=parent.version, rationale=rationale, feedback=feedback)
557
+ skill = load_skill(skills_root, new_version, expect_name=cfg.skill_name)
558
+ return ImproveResult(
559
+ skill=skill,
560
+ parent=parent.version,
561
+ cost_usd=result.total_cost_usd or 0.0,
562
+ turns=result.num_turns,
563
+ n_train_runs=len(train_runs),
564
+ n_train_failures=n_failures,
565
+ log_jsonl=log.jsonl_path if log is not None else None,
566
+ log_html=log.html_path if log is not None and log.html_rendered else None,
567
+ )
568
+ finally:
569
+ # Kill anything the agent left running before removing the directory it runs in.
570
+ reap(holder)
571
+ shutil.rmtree(holder, ignore_errors=True)
572
+
573
+
574
+ def _latest_or_error(skills_root: Path) -> str:
575
+ from acumen.skills import latest_version
576
+
577
+ latest = latest_version(skills_root)
578
+ if latest is None:
579
+ raise ImproveError(f"no skill versions found under {skills_root} — run `acumen draft` first, then bench it")
580
+ return latest
581
+
582
+
583
+ def _read_rationale(path: Path, result: ResultMessage, parent: str, new_version: str) -> str:
584
+ """Recover the agent's rationale for ``meta.json``.
585
+
586
+ Prefers the ``rationale.md`` the prompt asks for; falls back to the agent's final
587
+ message, then to a plain provenance note, so a version always records why it exists.
588
+ """
589
+ if path.is_file():
590
+ text = path.read_text().strip()
591
+ if text:
592
+ return text
593
+ final = (result.result or "").strip()
594
+ if final:
595
+ return final
596
+ return f"improved from {parent} to {new_version}"