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/__init__.py +160 -0
- acumen/_skills/__init__.py +0 -0
- acumen/_skills/data/SKILL.md +109 -0
- acumen/_skills/data/references/authoring.md +91 -0
- acumen/_skills/data/references/benchmark.md +100 -0
- acumen/_skills/data/references/python-api.md +106 -0
- acumen/_skills/data/references/setup.md +94 -0
- acumen/_skills/data/references/ship.md +53 -0
- acumen/_skills/install.py +185 -0
- acumen/assets/banner.svg +501 -0
- acumen/assets/logo.svg +47 -0
- acumen/bench.py +202 -0
- acumen/cli.py +626 -0
- acumen/config.py +204 -0
- acumen/draft.py +202 -0
- acumen/env.py +602 -0
- acumen/grade.py +104 -0
- acumen/improve.py +596 -0
- acumen/logs.py +255 -0
- acumen/paths.py +164 -0
- acumen/procs.py +195 -0
- acumen/prompts.py +942 -0
- acumen/report.py +1858 -0
- acumen/runner.py +343 -0
- acumen/sandbox.py +153 -0
- acumen/scaffold.py +118 -0
- acumen/ship.py +298 -0
- acumen/skills.py +298 -0
- acumen/taskgen.py +424 -0
- acumen/tasks.py +144 -0
- acumen/transcript.py +77 -0
- acumen-0.0.1.dev0.dist-info/METADATA +168 -0
- acumen-0.0.1.dev0.dist-info/RECORD +36 -0
- acumen-0.0.1.dev0.dist-info/WHEEL +4 -0
- acumen-0.0.1.dev0.dist-info/entry_points.txt +3 -0
- acumen-0.0.1.dev0.dist-info/licenses/LICENSE +21 -0
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}"
|