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/runner.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""SDK invocation for a single benchmark run.
|
|
2
|
+
|
|
3
|
+
Runs one agent in one sandbox, captures its transcript, grades what it wrote, and emits
|
|
4
|
+
``result.json`` — the unit of record. Writing ``result.json`` last is deliberate: its
|
|
5
|
+
presence is what marks a run complete, which is what makes ``--resume`` safe.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TextIO
|
|
17
|
+
|
|
18
|
+
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
|
|
19
|
+
|
|
20
|
+
from acumen.env import AuthMode, Target, sdk_version
|
|
21
|
+
from acumen.grade import Grade, Reason, grade_run
|
|
22
|
+
from acumen.paths import (
|
|
23
|
+
ANSWER_FILE,
|
|
24
|
+
RESULT_FILE,
|
|
25
|
+
SCRIPT_FILE,
|
|
26
|
+
TRANSCRIPT_HTML,
|
|
27
|
+
TRANSCRIPT_JSONL,
|
|
28
|
+
RunKey,
|
|
29
|
+
)
|
|
30
|
+
from acumen.prompts import benchmark_prompt
|
|
31
|
+
from acumen.sandbox import Sandbox, sandbox
|
|
32
|
+
from acumen.skills import Skill
|
|
33
|
+
from acumen.tasks import Task
|
|
34
|
+
from acumen.transcript import locate_transcript, render_transcript
|
|
35
|
+
|
|
36
|
+
#: Result subtypes the CLI reports on a cap breach, mapped to our reason taxonomy.
|
|
37
|
+
_SUBTYPE_REASONS: dict[str, Reason] = {
|
|
38
|
+
"error_max_budget_usd": "budget",
|
|
39
|
+
"error_max_turns": "max_turns",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RunOutcome:
|
|
45
|
+
"""What a run produced, as recorded in ``result.json``."""
|
|
46
|
+
|
|
47
|
+
key: RunKey
|
|
48
|
+
success: bool
|
|
49
|
+
reason: Reason
|
|
50
|
+
payload: dict
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _terminal_reason(message: ResultMessage) -> Reason | None:
|
|
54
|
+
"""Map a cap breach or hard error onto a reason, or ``None`` if the agent finished.
|
|
55
|
+
|
|
56
|
+
``subtype`` is a free-form string from the CLI, so match defensively rather than
|
|
57
|
+
against an exhaustive list we cannot see from here.
|
|
58
|
+
"""
|
|
59
|
+
subtype = (message.subtype or "").lower()
|
|
60
|
+
if subtype in _SUBTYPE_REASONS:
|
|
61
|
+
return _SUBTYPE_REASONS[subtype]
|
|
62
|
+
if "budget" in subtype:
|
|
63
|
+
return "budget"
|
|
64
|
+
if "max_turns" in subtype:
|
|
65
|
+
return "max_turns"
|
|
66
|
+
if message.is_error or subtype.startswith("error"):
|
|
67
|
+
return "error"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_transcript(box: Sandbox, session_id: str) -> Path | None:
|
|
72
|
+
return locate_transcript(box.config_dir, box.root, session_id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _skill_fired(jsonl: Path, name: str) -> bool | None:
|
|
76
|
+
"""Return whether the agent loaded the skill called ``name``, read from the transcript.
|
|
77
|
+
|
|
78
|
+
This is the evidence the skill arm actually used the skill — the skill arm must show it
|
|
79
|
+
loading and the baseline must not — so it is recorded per run rather than left to
|
|
80
|
+
hand-inspection. ``None`` means the transcript was unavailable, which is not the same
|
|
81
|
+
as the skill not loading.
|
|
82
|
+
|
|
83
|
+
The match is on the skill *identity*, not on the tool: a ``Skill`` call carries the
|
|
84
|
+
requested skill in ``input.skill``, and the CLI ships bundled skills of its own that the
|
|
85
|
+
agent can reach in either arm. Loading one of those says nothing about the skill under
|
|
86
|
+
test, so only ``input.skill == name`` counts.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
lines = jsonl.read_text().splitlines()
|
|
90
|
+
except OSError:
|
|
91
|
+
return None
|
|
92
|
+
for line in lines:
|
|
93
|
+
try:
|
|
94
|
+
record = json.loads(line)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
continue
|
|
97
|
+
if record.get("type") != "assistant":
|
|
98
|
+
continue
|
|
99
|
+
content = record.get("message", {}).get("content")
|
|
100
|
+
if not isinstance(content, list):
|
|
101
|
+
continue
|
|
102
|
+
for block in content:
|
|
103
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use" or block.get("name") != "Skill":
|
|
104
|
+
continue
|
|
105
|
+
payload = block.get("input")
|
|
106
|
+
if isinstance(payload, dict) and payload.get("skill") == name:
|
|
107
|
+
return True
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _collect_artifacts(box: Sandbox, run_dir: Path) -> None:
|
|
112
|
+
for name in (ANSWER_FILE, SCRIPT_FILE):
|
|
113
|
+
src = box.root / name
|
|
114
|
+
if src.is_file():
|
|
115
|
+
shutil.copyfile(src, run_dir / name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _usage_tokens(usage: dict | None) -> tuple[int, int]:
|
|
119
|
+
if not usage:
|
|
120
|
+
return 0, 0
|
|
121
|
+
inp = int(usage.get("input_tokens", 0) or 0)
|
|
122
|
+
inp += int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
123
|
+
inp += int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
124
|
+
return inp, int(usage.get("output_tokens", 0) or 0)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class StderrFilter:
|
|
128
|
+
"""Dedupe the CLI subprocess stderr the SDK forwards, keeping the first of each line.
|
|
129
|
+
|
|
130
|
+
A benchmark pass spawns one CLI subprocess per run, and each reprints the same
|
|
131
|
+
startup diagnostics — most visibly the ``claude.ai connectors are disabled`` notice,
|
|
132
|
+
which fires on every spawn because an auth source is present in the agents' env. Left
|
|
133
|
+
alone that is one identical warning per run.
|
|
134
|
+
|
|
135
|
+
Share a single instance across a whole pass and hand it to :func:`run_matrix` as its
|
|
136
|
+
``stderr`` callback. The SDK pipes subprocess stderr only when a callback is set and
|
|
137
|
+
then invokes it one line at a time on the event loop, so this becomes the sole sink:
|
|
138
|
+
the first time a given line is seen it is forwarded to our own stderr, and every later
|
|
139
|
+
repeat is dropped. Distinct lines still all pass through; only exact repeats are cut.
|
|
140
|
+
|
|
141
|
+
Called on a single-threaded event loop, so the seen-set needs no locking.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, sink: TextIO | None = None) -> None:
|
|
145
|
+
self._seen: set[str] = set()
|
|
146
|
+
self._sink = sink if sink is not None else sys.stderr
|
|
147
|
+
|
|
148
|
+
def __call__(self, line: str) -> None:
|
|
149
|
+
"""Forward ``line`` the first time it is seen; drop it on every later repeat."""
|
|
150
|
+
if line in self._seen:
|
|
151
|
+
return
|
|
152
|
+
self._seen.add(line)
|
|
153
|
+
print(line, file=self._sink, flush=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_options(
|
|
157
|
+
*,
|
|
158
|
+
box: Sandbox,
|
|
159
|
+
model: str,
|
|
160
|
+
max_turns: int,
|
|
161
|
+
max_usd: float,
|
|
162
|
+
stderr: Callable[[str], None] | None = None,
|
|
163
|
+
) -> ClaudeAgentOptions:
|
|
164
|
+
"""Assemble the SDK options for one run.
|
|
165
|
+
|
|
166
|
+
These options are **identical for every arm** — baseline parity means the
|
|
167
|
+
noskill and skill arms differ only in whether a skill directory exists inside the
|
|
168
|
+
sandbox, never in how the agent is configured.
|
|
169
|
+
|
|
170
|
+
``setting_sources`` is always set explicitly, never left to default: the default of
|
|
171
|
+
``None`` loads user settings *and* memories, and leaving it unset while
|
|
172
|
+
passing ``skills`` silently re-enables the ``"user"`` source.
|
|
173
|
+
``["project"]`` scopes discovery to ``<sandbox>/.claude/``, which is exactly where
|
|
174
|
+
the skill is installed — empirically verified, along with the fact that
|
|
175
|
+
``[]`` disables skill discovery entirely.
|
|
176
|
+
|
|
177
|
+
``skills`` is deliberately left ``None``: with ``setting_sources=["project"]`` the
|
|
178
|
+
skill is discovered from the sandbox anyway (verified), whereas naming it here would
|
|
179
|
+
append ``Skill(<name>)`` to ``allowed_tools`` in the skill arm only — an options-level
|
|
180
|
+
difference between arms, which is the one thing parity forbids.
|
|
181
|
+
"""
|
|
182
|
+
return ClaudeAgentOptions(
|
|
183
|
+
cwd=str(box.root),
|
|
184
|
+
env=box.env,
|
|
185
|
+
model=model,
|
|
186
|
+
max_turns=max_turns,
|
|
187
|
+
max_budget_usd=max_usd,
|
|
188
|
+
setting_sources=["project"],
|
|
189
|
+
permission_mode="bypassPermissions",
|
|
190
|
+
system_prompt={"type": "preset", "preset": "claude_code"},
|
|
191
|
+
stderr=stderr,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def run_once(
|
|
196
|
+
*,
|
|
197
|
+
key: RunKey,
|
|
198
|
+
task: Task,
|
|
199
|
+
target: Target,
|
|
200
|
+
run_dir: Path,
|
|
201
|
+
model: str,
|
|
202
|
+
max_turns: int,
|
|
203
|
+
max_usd: float,
|
|
204
|
+
auth_mode: AuthMode = "api",
|
|
205
|
+
skill: Skill | None = None,
|
|
206
|
+
skill_name: str | None = None,
|
|
207
|
+
sandbox_base: Path | None = None,
|
|
208
|
+
keep_sandbox: bool = False,
|
|
209
|
+
stderr: Callable[[str], None] | None = None,
|
|
210
|
+
env_passthrough: Sequence[str] | None = None,
|
|
211
|
+
) -> RunOutcome:
|
|
212
|
+
"""Execute one benchmark run end to end and write its ``result.json``.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
key
|
|
217
|
+
Identifies the run; also determines ``run_dir`` upstream.
|
|
218
|
+
task
|
|
219
|
+
The task being run; ``key.split`` selects which half.
|
|
220
|
+
target
|
|
221
|
+
The prepared target, supplying the interpreter and fingerprint.
|
|
222
|
+
run_dir
|
|
223
|
+
Where the five run files are written.
|
|
224
|
+
model, max_turns, max_usd
|
|
225
|
+
Already resolved against per-task overrides by the caller.
|
|
226
|
+
auth_mode
|
|
227
|
+
Which credential benchmark runs authenticate with; always ``"api"`` so the recorded
|
|
228
|
+
``cost_usd`` reflects real metered spend.
|
|
229
|
+
skill
|
|
230
|
+
The skill to install, or ``None`` for the baseline arm. Must agree with
|
|
231
|
+
``key.arm``, else the run would be filed under an arm it doesn't belong to.
|
|
232
|
+
skill_name
|
|
233
|
+
Name of the skill under test, used to recognise it in the transcript. Defaults to
|
|
234
|
+
the installed skill's name; pass it in the baseline arm too, where nothing is
|
|
235
|
+
installed but a stray load of that same skill is exactly what must be caught.
|
|
236
|
+
Without either, ``skill_loaded`` is left ``None`` — unattributable, not absent.
|
|
237
|
+
sandbox_base
|
|
238
|
+
Parent directory for the throwaway sandbox.
|
|
239
|
+
keep_sandbox
|
|
240
|
+
Leave the sandbox on disk — useful when hand-checking a failure.
|
|
241
|
+
stderr
|
|
242
|
+
Callback for the CLI subprocess's stderr, one line at a time. Pass a shared
|
|
243
|
+
:class:`StderrFilter` across a pass to collapse the per-spawn startup warnings;
|
|
244
|
+
``None`` (the default) lets the subprocess stderr inherit the terminal unfiltered.
|
|
245
|
+
env_passthrough
|
|
246
|
+
Extra environment variable names to carry into the sandbox on top of the built-in
|
|
247
|
+
allowlist (the operator's ``config.env_passthrough``).
|
|
248
|
+
|
|
249
|
+
Returns
|
|
250
|
+
-------
|
|
251
|
+
The outcome, matching what was written to disk.
|
|
252
|
+
"""
|
|
253
|
+
if (key.skill is None) != (skill is None):
|
|
254
|
+
# Filing a skill run under noskill/ (or vice versa) would silently corrupt the
|
|
255
|
+
# comparison the whole benchmark rests on, so refuse rather than record it.
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"arm {key.arm!r} expects skill {key.skill!r} but was given {skill.version if skill else None!r}"
|
|
258
|
+
)
|
|
259
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
split = task.split(key.split)
|
|
261
|
+
|
|
262
|
+
result: ResultMessage | None = None
|
|
263
|
+
error: str | None = None
|
|
264
|
+
|
|
265
|
+
with sandbox(
|
|
266
|
+
target,
|
|
267
|
+
auth_mode=auth_mode,
|
|
268
|
+
base=sandbox_base,
|
|
269
|
+
keep=keep_sandbox,
|
|
270
|
+
skill=skill,
|
|
271
|
+
env_passthrough=env_passthrough,
|
|
272
|
+
) as box:
|
|
273
|
+
prompt = benchmark_prompt(
|
|
274
|
+
split.prompt,
|
|
275
|
+
sandbox=box.root,
|
|
276
|
+
python=target.python,
|
|
277
|
+
package=target.pkg_name,
|
|
278
|
+
)
|
|
279
|
+
options = _build_options(box=box, model=model, max_turns=max_turns, max_usd=max_usd, stderr=stderr)
|
|
280
|
+
try:
|
|
281
|
+
async for message in query(prompt=prompt, options=options):
|
|
282
|
+
if isinstance(message, ResultMessage):
|
|
283
|
+
result = message
|
|
284
|
+
except Exception as err: # noqa: BLE001 - a crashed agent is a recorded failure, not a crashed pass
|
|
285
|
+
error = f"{type(err).__name__}: {err}"
|
|
286
|
+
|
|
287
|
+
_collect_artifacts(box, run_dir)
|
|
288
|
+
if result is not None:
|
|
289
|
+
jsonl = _find_transcript(box, result.session_id)
|
|
290
|
+
if jsonl is not None:
|
|
291
|
+
shutil.copyfile(jsonl, run_dir / TRANSCRIPT_JSONL)
|
|
292
|
+
|
|
293
|
+
rendered = False
|
|
294
|
+
skill_loaded: bool | None = None
|
|
295
|
+
expected_skill = skill.name if skill is not None else skill_name
|
|
296
|
+
if (run_dir / TRANSCRIPT_JSONL).is_file():
|
|
297
|
+
rendered = render_transcript(run_dir / TRANSCRIPT_JSONL, run_dir / TRANSCRIPT_HTML)
|
|
298
|
+
if expected_skill is not None:
|
|
299
|
+
skill_loaded = _skill_fired(run_dir / TRANSCRIPT_JSONL, expected_skill)
|
|
300
|
+
|
|
301
|
+
grade: Grade = grade_run(run_dir, split.answer)
|
|
302
|
+
if error is not None or result is None:
|
|
303
|
+
success, reason = False, "error"
|
|
304
|
+
else:
|
|
305
|
+
terminal = _terminal_reason(result)
|
|
306
|
+
if terminal is not None:
|
|
307
|
+
# A cap breach is a failure regardless of what landed in answer.md.
|
|
308
|
+
success, reason = False, terminal
|
|
309
|
+
else:
|
|
310
|
+
success, reason = grade.success, grade.reason
|
|
311
|
+
|
|
312
|
+
inp, out = _usage_tokens(result.usage if result else None)
|
|
313
|
+
payload = {
|
|
314
|
+
"task_id": key.task_id,
|
|
315
|
+
"split": key.split,
|
|
316
|
+
"skill": key.skill,
|
|
317
|
+
"arm": key.arm,
|
|
318
|
+
"model": model,
|
|
319
|
+
"rep": key.rep,
|
|
320
|
+
"success": success,
|
|
321
|
+
"reason": reason,
|
|
322
|
+
"turns": result.num_turns if result else 0,
|
|
323
|
+
"cost_usd": (result.total_cost_usd if result else None) or 0.0,
|
|
324
|
+
"input_tokens": inp,
|
|
325
|
+
"output_tokens": out,
|
|
326
|
+
"duration_s": round((result.duration_ms if result else 0) / 1000, 2),
|
|
327
|
+
"answer": grade.answer,
|
|
328
|
+
"expected": split.answer.strip(),
|
|
329
|
+
"pkg_version": target.fingerprint,
|
|
330
|
+
"commit": target.commit,
|
|
331
|
+
"skill_hash": skill.hash if skill else None,
|
|
332
|
+
"skill_name": skill.name if skill else None,
|
|
333
|
+
# Evidence, not configuration: did the agent actually invoke the Skill tool?
|
|
334
|
+
"skill_loaded": skill_loaded,
|
|
335
|
+
"sdk_version": sdk_version(),
|
|
336
|
+
"session_id": result.session_id if result else None,
|
|
337
|
+
"stop_reason": result.stop_reason if result else None,
|
|
338
|
+
"subtype": result.subtype if result else None,
|
|
339
|
+
"transcript_html": rendered,
|
|
340
|
+
"error": error or (result.errors if result else None),
|
|
341
|
+
}
|
|
342
|
+
(run_dir / RESULT_FILE).write_text(json.dumps(payload, indent=2) + "\n")
|
|
343
|
+
return RunOutcome(key=key, success=success, reason=reason, payload=payload)
|
acumen/sandbox.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Per-run sandbox: a fresh empty directory with the target venv on PATH.
|
|
2
|
+
|
|
3
|
+
The agent sees an installed package and nothing else — no repo source, no user
|
|
4
|
+
settings, no memories. Each run gets its own sandbox, throwaway ``HOME`` and throwaway
|
|
5
|
+
``CLAUDE_CONFIG_DIR``, so runs cannot see each other either.
|
|
6
|
+
|
|
7
|
+
A skill arm differs from the baseline in exactly one way: ``skills/v{n}/`` is
|
|
8
|
+
copied into ``<sandbox>/.claude/skills/<name>/``, where the agent's own project settings
|
|
9
|
+
discovery finds it. Same prompt, same tools, same caps, same env otherwise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import tempfile
|
|
16
|
+
from collections.abc import Iterator, Sequence
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from acumen.env import AuthMode, Target, build_agent_env
|
|
22
|
+
from acumen.procs import label_env, reap
|
|
23
|
+
from acumen.skills import Skill, content_files
|
|
24
|
+
|
|
25
|
+
#: Where the ``claude`` CLI looks for project skills, relative to the agent's cwd.
|
|
26
|
+
SKILLS_SUBDIR = Path(".claude") / "skills"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Sandbox:
|
|
31
|
+
"""A prepared, isolated working directory for exactly one agent run."""
|
|
32
|
+
|
|
33
|
+
root: Path
|
|
34
|
+
home: Path
|
|
35
|
+
config_dir: Path
|
|
36
|
+
env: dict[str, str]
|
|
37
|
+
authenticated: bool
|
|
38
|
+
skill: Skill | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def transcript_root(self) -> Path:
|
|
42
|
+
"""Where the ``claude`` CLI writes transcripts for this run."""
|
|
43
|
+
return self.config_dir / "projects"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def skills_dir(self) -> Path:
|
|
47
|
+
"""The project skills directory the agent discovers, ``<root>/.claude/skills``."""
|
|
48
|
+
return self.root / SKILLS_SUBDIR
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def skill_hash(self) -> str | None:
|
|
52
|
+
"""The installed skill's content hash, or ``None`` for the baseline arm."""
|
|
53
|
+
return self.skill.hash if self.skill else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def install_skill(root: Path, skill: Skill) -> Path:
|
|
57
|
+
"""Copy a skill's content into a sandbox as a discoverable project skill.
|
|
58
|
+
|
|
59
|
+
``meta.json`` is deliberately left behind: it is acumen's provenance record, and the
|
|
60
|
+
agent consuming the skill has no business reading the rationale for its own version.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
root
|
|
65
|
+
The sandbox root — the agent's ``cwd``.
|
|
66
|
+
skill
|
|
67
|
+
The loaded skill to install.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
The installed skill directory, ``<root>/.claude/skills/<name>``.
|
|
72
|
+
"""
|
|
73
|
+
dest = root / SKILLS_SUBDIR / skill.name
|
|
74
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
for src in content_files(skill.directory):
|
|
76
|
+
target = dest / src.relative_to(skill.directory)
|
|
77
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
shutil.copyfile(src, target)
|
|
79
|
+
return dest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextmanager
|
|
83
|
+
def sandbox(
|
|
84
|
+
target: Target,
|
|
85
|
+
*,
|
|
86
|
+
auth_mode: AuthMode,
|
|
87
|
+
base: Path | None = None,
|
|
88
|
+
keep: bool = False,
|
|
89
|
+
skill: Skill | None = None,
|
|
90
|
+
env_passthrough: Sequence[str] | None = None,
|
|
91
|
+
) -> Iterator[Sandbox]:
|
|
92
|
+
"""Create a fresh sandbox for one run and clean it up afterwards.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
target
|
|
97
|
+
The prepared target; its venv ``bin`` goes on the sandbox PATH.
|
|
98
|
+
auth_mode
|
|
99
|
+
Which credential the run authenticates with (see :func:`acumen.env.build_agent_env`).
|
|
100
|
+
Benchmark runs always pass ``"api"`` so the recorded ``cost_usd`` is real.
|
|
101
|
+
base
|
|
102
|
+
Parent directory for the sandbox. Defaults to the system temp dir.
|
|
103
|
+
keep
|
|
104
|
+
Leave the sandbox on disk after the run — useful when debugging a failure.
|
|
105
|
+
skill
|
|
106
|
+
Skill to install for this run, or ``None`` for the baseline arm.
|
|
107
|
+
env_passthrough
|
|
108
|
+
Extra environment variable names to carry into the sandbox on top of the built-in
|
|
109
|
+
allowlist (the operator's ``config.env_passthrough``).
|
|
110
|
+
|
|
111
|
+
Yields
|
|
112
|
+
------
|
|
113
|
+
The sandbox, whose ``root`` is the agent's ``cwd``.
|
|
114
|
+
"""
|
|
115
|
+
if base is not None:
|
|
116
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
holder = Path(tempfile.mkdtemp(prefix="acumen-run-", dir=base))
|
|
118
|
+
try:
|
|
119
|
+
root = holder / "work"
|
|
120
|
+
home = holder / "home"
|
|
121
|
+
config_dir = home / ".claude"
|
|
122
|
+
for path in (root, home, config_dir, home / "tmp"):
|
|
123
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
if skill is not None:
|
|
125
|
+
install_skill(root, skill)
|
|
126
|
+
# The marker is what lets the teardown below find whatever this agent leaves running.
|
|
127
|
+
env = label_env(
|
|
128
|
+
build_agent_env(
|
|
129
|
+
config_dir=config_dir,
|
|
130
|
+
home=home,
|
|
131
|
+
extra_path=[target.bin_dir],
|
|
132
|
+
auth_mode=auth_mode,
|
|
133
|
+
extra_allow=env_passthrough,
|
|
134
|
+
),
|
|
135
|
+
holder,
|
|
136
|
+
)
|
|
137
|
+
yield Sandbox(
|
|
138
|
+
root=root,
|
|
139
|
+
home=home,
|
|
140
|
+
config_dir=config_dir,
|
|
141
|
+
env=env,
|
|
142
|
+
# Only session mode seeds the OAuth login into the throwaway config dir.
|
|
143
|
+
authenticated=auth_mode == "session",
|
|
144
|
+
skill=skill,
|
|
145
|
+
)
|
|
146
|
+
finally:
|
|
147
|
+
# The agent's own descendants outlive it — a backgrounded job, or anything still
|
|
148
|
+
# running when the CLI was terminated on a cap breach or a Ctrl-C. Kill them before
|
|
149
|
+
# the directory goes, so nothing is left writing into a path that no longer exists.
|
|
150
|
+
# ``keep`` preserves the files for inspection, never the processes.
|
|
151
|
+
reap(holder)
|
|
152
|
+
if not keep:
|
|
153
|
+
shutil.rmtree(holder, ignore_errors=True)
|
acumen/scaffold.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Scaffold starter ``config.yaml`` and ``tasks.yaml`` files for ``acumen init``.
|
|
2
|
+
|
|
3
|
+
The templates are package-agnostic placeholders, kept heavily commented so a new user can
|
|
4
|
+
read what each field does and fill in the blanks rather than starting from an empty file.
|
|
5
|
+
The placeholders (``OWNER/REPO``, the ``REPLACE_ME`` answers) are meant to be edited before
|
|
6
|
+
the first run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CONFIG_FILENAME = "config.yaml"
|
|
14
|
+
TASKS_FILENAME = "tasks.yaml"
|
|
15
|
+
|
|
16
|
+
#: An annotated config with placeholder target/skill fields for the user to fill in.
|
|
17
|
+
CONFIG_TEMPLATE = """\
|
|
18
|
+
# acumen config — the target package and how to benchmark against it.
|
|
19
|
+
# Every field below except `repo` has a default; delete a line to take it.
|
|
20
|
+
|
|
21
|
+
repo: https://github.com/OWNER/REPO # GitHub URL, or a local path to an installable package
|
|
22
|
+
ref: main # branch, tag, or commit (ignored for local paths)
|
|
23
|
+
extras: [] # optional pip extras, e.g. [dev, test]
|
|
24
|
+
python: "3.12" # interpreter for the target's throwaway venv
|
|
25
|
+
env_passthrough: [] # extra env vars agents may keep (e.g. [OMP_NUM_THREADS]); the agent env is otherwise a clean allowlist
|
|
26
|
+
|
|
27
|
+
models: [claude-opus-5, claude-sonnet-5, claude-haiku-4-5-20251001] # benchmark models; a pass is models x tasks x reps x splits
|
|
28
|
+
n_replicates: 3 # runs per (model, task, split) cell
|
|
29
|
+
max_concurrency: 4 # simultaneous agents
|
|
30
|
+
|
|
31
|
+
max_turns: 40 # turn cap for benchmark agents; draft/improve/ship/tasks are unbounded
|
|
32
|
+
max_usd: 3.0 # budget cap (USD) for benchmark agents; draft/improve/ship/tasks are unbounded
|
|
33
|
+
|
|
34
|
+
draft_model: claude-opus-5 # model that writes the first skill (reads the source)
|
|
35
|
+
improve_model: claude-opus-5 # model that improves a skill from its train results
|
|
36
|
+
tasks_model: claude-opus-5 # model for `acumen tasks` (generates this file); runs code
|
|
37
|
+
ship_model: claude-opus-5 # model for `acumen ship` (wires a multi-framework install-skills script)
|
|
38
|
+
|
|
39
|
+
# skill_name: your_skill # name for the built skill; defaults to the repo's name
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
#: A tasks file with one placeholder task, showing the required shape. Each task needs a
|
|
43
|
+
#: stable `id` and a train/test pair; the answer is graded by exact string match after
|
|
44
|
+
#: `strip()`.
|
|
45
|
+
TASKS_TEMPLATE = """\
|
|
46
|
+
# acumen tasks — what the agent is asked to do, and the answer it is graded against.
|
|
47
|
+
#
|
|
48
|
+
# Each task has a stable `id` (used in run paths — renaming it orphans old runs) and a
|
|
49
|
+
# train/test pair. The improver only ever sees train results; test is the held-out measure
|
|
50
|
+
# of whether a skill actually helps. Answers are compared by EXACT string match after
|
|
51
|
+
# strip(), so keep them to a single unambiguous token.
|
|
52
|
+
#
|
|
53
|
+
# Do NOT name the target package in a prompt. The agent is already told, before every task,
|
|
54
|
+
# that your package (from `config.yaml`) is installed and is the one to use — so "using
|
|
55
|
+
# <package>, ..." is redundant. Describe only the analysis: the goal, the input, and exactly
|
|
56
|
+
# what to report. (`acumen tasks` follows the same rule when it generates this file.)
|
|
57
|
+
#
|
|
58
|
+
# A good task is one your target package solves and the baseline gets wrong — that is where
|
|
59
|
+
# a skill has room to help. Replace the placeholders below with your own, or run
|
|
60
|
+
# `acumen tasks` to generate this file automatically from the target package.
|
|
61
|
+
#
|
|
62
|
+
# Optional per-task overrides: `max_turns`, `max_usd`, `model`.
|
|
63
|
+
|
|
64
|
+
tasks:
|
|
65
|
+
- id: example_task
|
|
66
|
+
train:
|
|
67
|
+
prompt: >-
|
|
68
|
+
Describe, concretely, the analysis the agent should carry out on the training input
|
|
69
|
+
— the data, the goal, and exactly what to report. Do not name the package (it is
|
|
70
|
+
provided). End by asking for only the final answer, so it grades as a single token.
|
|
71
|
+
answer: REPLACE_ME_TRAIN
|
|
72
|
+
test:
|
|
73
|
+
prompt: >-
|
|
74
|
+
The same kind of analysis on a different, held-out input. This split is what measures
|
|
75
|
+
whether the skill generalizes; the improver never sees its results.
|
|
76
|
+
answer: REPLACE_ME_TEST
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InitError(ValueError):
|
|
81
|
+
"""Raised when scaffolding cannot proceed — e.g. files exist and ``force`` is unset."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scaffold(directory: Path, *, force: bool = False) -> list[Path]:
|
|
85
|
+
"""Write starter ``config.yaml`` and ``tasks.yaml`` into ``directory``.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
directory
|
|
90
|
+
Where to scaffold. Created if it does not exist.
|
|
91
|
+
force
|
|
92
|
+
Overwrite existing files. Without it, an existing ``config.yaml`` or ``tasks.yaml``
|
|
93
|
+
is left untouched and an error is raised, so a real project is never clobbered.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
The paths written, in the order they were written.
|
|
98
|
+
|
|
99
|
+
Raises
|
|
100
|
+
------
|
|
101
|
+
InitError
|
|
102
|
+
If a target file exists and ``force`` is not set.
|
|
103
|
+
"""
|
|
104
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
targets = {directory / CONFIG_FILENAME: CONFIG_TEMPLATE, directory / TASKS_FILENAME: TASKS_TEMPLATE}
|
|
106
|
+
|
|
107
|
+
if not force:
|
|
108
|
+
existing = [p for p in targets if p.exists()]
|
|
109
|
+
if existing:
|
|
110
|
+
names = ", ".join(p.name for p in existing)
|
|
111
|
+
verb = "exists" if len(existing) == 1 else "exist"
|
|
112
|
+
raise InitError(f"{names} already {verb} in {directory} — pass --force to overwrite")
|
|
113
|
+
|
|
114
|
+
written = []
|
|
115
|
+
for path, template in targets.items():
|
|
116
|
+
path.write_text(template)
|
|
117
|
+
written.append(path)
|
|
118
|
+
return written
|