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/config.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Config schema and loader for ``config.yaml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .paths import PathError, slugify
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(ValueError):
|
|
15
|
+
"""Raised when ``config.yaml`` is malformed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Config:
|
|
20
|
+
"""Everything a pass needs that isn't a task: target, models, budgets, concurrency."""
|
|
21
|
+
|
|
22
|
+
repo: str
|
|
23
|
+
skill_name: str
|
|
24
|
+
ref: str = "main"
|
|
25
|
+
extras: list[str] = field(default_factory=list)
|
|
26
|
+
python: str = "3.12"
|
|
27
|
+
#: Names of environment variables to carry from the operator's shell into isolated
|
|
28
|
+
#: agents (bench sandboxes and the draft/improve/tasks meta-agents), on top of the
|
|
29
|
+
#: built-in auth/proxy allowlist. The agent env is otherwise a clean allowlist — every
|
|
30
|
+
#: other inherited variable is blanked — so a target that needs a runtime variable
|
|
31
|
+
#: (``OMP_NUM_THREADS``, ``R_HOME``, a service key) must name it here. Values are passed
|
|
32
|
+
#: through verbatim; only list what the package genuinely needs.
|
|
33
|
+
env_passthrough: list[str] = field(default_factory=list)
|
|
34
|
+
models: list[str] = field(default_factory=lambda: ["claude-opus-5"])
|
|
35
|
+
n_replicates: int = 3
|
|
36
|
+
max_concurrency: int = 4
|
|
37
|
+
#: Turn cap for benchmark agents only; draft/improve/ship/tasks are unbounded by default.
|
|
38
|
+
max_turns: int = 40
|
|
39
|
+
#: Budget cap (USD) for benchmark agents only; draft/improve/ship/tasks are unbounded by default.
|
|
40
|
+
max_usd: float = 3.0
|
|
41
|
+
draft_model: str = "claude-opus-5"
|
|
42
|
+
improve_model: str = "claude-opus-5"
|
|
43
|
+
tasks_model: str = "claude-opus-5"
|
|
44
|
+
ship_model: str = "claude-opus-5"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_local(self) -> bool:
|
|
48
|
+
"""Whether ``repo`` points at a local path rather than a remote URL."""
|
|
49
|
+
return not _looks_remote(self.repo)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_REMOTE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://")
|
|
53
|
+
|
|
54
|
+
_KNOWN = {
|
|
55
|
+
"repo",
|
|
56
|
+
"ref",
|
|
57
|
+
"extras",
|
|
58
|
+
"python",
|
|
59
|
+
"env_passthrough",
|
|
60
|
+
"models",
|
|
61
|
+
"n_replicates",
|
|
62
|
+
"max_concurrency",
|
|
63
|
+
"max_turns",
|
|
64
|
+
"max_usd",
|
|
65
|
+
"draft_model",
|
|
66
|
+
"improve_model",
|
|
67
|
+
"tasks_model",
|
|
68
|
+
"ship_model",
|
|
69
|
+
"skill_name",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _looks_remote(repo: str) -> bool:
|
|
74
|
+
return repo.startswith(_REMOTE_PREFIXES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def derive_skill_name(repo: str) -> str:
|
|
78
|
+
"""Derive a default skill name from a ``repo`` URL or local path.
|
|
79
|
+
|
|
80
|
+
Takes the last path component (``.../scanpy`` or ``git@…:owner/scanpy.git`` ->
|
|
81
|
+
``scanpy``), drops a trailing ``.git``, and slugifies it to a lowercase, filesystem-safe
|
|
82
|
+
token. This is the fallback when ``config.yaml`` omits ``skill_name``, so the name is one
|
|
83
|
+
fewer field a user can get wrong.
|
|
84
|
+
"""
|
|
85
|
+
stem = Path(repo.rstrip("/")).name.removesuffix(".git")
|
|
86
|
+
try:
|
|
87
|
+
return slugify(stem).lower()
|
|
88
|
+
except PathError as err:
|
|
89
|
+
raise ConfigError(f"cannot derive a skill name from repo {repo!r}; set 'skill_name' explicitly") from err
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_str(raw: dict[str, Any], key: str) -> str:
|
|
93
|
+
value = raw[key]
|
|
94
|
+
if not isinstance(value, str) or not value.strip():
|
|
95
|
+
raise ConfigError(f"'{key}' must be a non-empty string, got {value!r}")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _optional_str(raw: dict[str, Any], key: str, default: str) -> str:
|
|
100
|
+
if key not in raw:
|
|
101
|
+
return default
|
|
102
|
+
return _require_str(raw, key)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _positive_int(raw: dict[str, Any], key: str, default: int) -> int:
|
|
106
|
+
if key not in raw:
|
|
107
|
+
return default
|
|
108
|
+
value = raw[key]
|
|
109
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
110
|
+
raise ConfigError(f"'{key}' must be a positive integer, got {value!r}")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _positive_float(raw: dict[str, Any], key: str, default: float) -> float:
|
|
115
|
+
if key not in raw:
|
|
116
|
+
return default
|
|
117
|
+
value = raw[key]
|
|
118
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or value <= 0:
|
|
119
|
+
raise ConfigError(f"'{key}' must be a positive number, got {value!r}")
|
|
120
|
+
return float(value)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _str_list(raw: dict[str, Any], key: str, default: list[str]) -> list[str]:
|
|
124
|
+
if key not in raw:
|
|
125
|
+
return list(default)
|
|
126
|
+
value = raw[key]
|
|
127
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
|
|
128
|
+
raise ConfigError(f"'{key}' must be a list of non-empty strings, got {value!r}")
|
|
129
|
+
return list(value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_config(raw: Any) -> Config:
|
|
133
|
+
"""Validate an already-parsed ``config.yaml`` document.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
raw
|
|
138
|
+
The mapping loaded from YAML.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
The validated config.
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(raw, dict):
|
|
145
|
+
raise ConfigError(f"config.yaml must be a mapping, got {type(raw).__name__}")
|
|
146
|
+
unknown = set(raw) - _KNOWN
|
|
147
|
+
if unknown:
|
|
148
|
+
raise ConfigError(f"unknown keys: {sorted(unknown)} (known keys: {sorted(_KNOWN)})")
|
|
149
|
+
if "repo" not in raw:
|
|
150
|
+
raise ConfigError("missing required key 'repo'")
|
|
151
|
+
|
|
152
|
+
repo = _require_str(raw, "repo")
|
|
153
|
+
skill_name = _require_str(raw, "skill_name") if "skill_name" in raw else derive_skill_name(repo)
|
|
154
|
+
|
|
155
|
+
models = _str_list(raw, "models", ["claude-opus-5"])
|
|
156
|
+
if not models:
|
|
157
|
+
raise ConfigError("'models' must list at least one model")
|
|
158
|
+
if len(set(models)) != len(models):
|
|
159
|
+
raise ConfigError(f"'models' contains duplicates: {models}")
|
|
160
|
+
|
|
161
|
+
default_model = models[0]
|
|
162
|
+
return Config(
|
|
163
|
+
repo=repo,
|
|
164
|
+
skill_name=skill_name,
|
|
165
|
+
ref=_optional_str(raw, "ref", "main"),
|
|
166
|
+
extras=_str_list(raw, "extras", []),
|
|
167
|
+
python=_optional_str(raw, "python", "3.12"),
|
|
168
|
+
env_passthrough=_str_list(raw, "env_passthrough", []),
|
|
169
|
+
models=models,
|
|
170
|
+
n_replicates=_positive_int(raw, "n_replicates", 3),
|
|
171
|
+
max_concurrency=_positive_int(raw, "max_concurrency", 4),
|
|
172
|
+
max_turns=_positive_int(raw, "max_turns", 40),
|
|
173
|
+
max_usd=_positive_float(raw, "max_usd", 3.0),
|
|
174
|
+
draft_model=_optional_str(raw, "draft_model", default_model),
|
|
175
|
+
improve_model=_optional_str(raw, "improve_model", default_model),
|
|
176
|
+
tasks_model=_optional_str(raw, "tasks_model", default_model),
|
|
177
|
+
ship_model=_optional_str(raw, "ship_model", default_model),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_config(path: Path) -> Config:
|
|
182
|
+
"""Load and validate ``config.yaml`` from disk.
|
|
183
|
+
|
|
184
|
+
A local ``repo`` path is resolved relative to the config file, so a config is
|
|
185
|
+
portable with the directory that contains it.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
text = path.read_text()
|
|
189
|
+
except OSError as err:
|
|
190
|
+
raise ConfigError(f"cannot read config file {path}: {err}") from err
|
|
191
|
+
try:
|
|
192
|
+
raw = yaml.safe_load(text)
|
|
193
|
+
except yaml.YAMLError as err:
|
|
194
|
+
raise ConfigError(f"{path} is not valid YAML: {err}") from err
|
|
195
|
+
try:
|
|
196
|
+
cfg = parse_config(raw)
|
|
197
|
+
except ConfigError as err:
|
|
198
|
+
raise ConfigError(f"{path}: {err}") from err
|
|
199
|
+
if cfg.is_local:
|
|
200
|
+
resolved = (path.parent / cfg.repo).expanduser()
|
|
201
|
+
if not resolved.exists():
|
|
202
|
+
raise ConfigError(f"{path}: repo {cfg.repo!r} is not a URL and does not exist as a local path ({resolved})")
|
|
203
|
+
cfg = replace(cfg, repo=str(resolved.resolve()))
|
|
204
|
+
return cfg
|
acumen/draft.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The drafting agent: write ``skills/v1/`` from the target package's source.
|
|
2
|
+
|
|
3
|
+
Unlike a benchmark agent, the drafter reads the target's source — it is documenting
|
|
4
|
+
the package, so it needs to see it. It is otherwise held to the same isolation: scrubbed
|
|
5
|
+
env, throwaway config dir, no user settings or memories.
|
|
6
|
+
|
|
7
|
+
The agent writes into a staging directory, not into ``skills/`` directly. Only a skill
|
|
8
|
+
that loads and validates is promoted to a version, so a failed or half-finished draft
|
|
9
|
+
never leaves a broken ``skills/vN/`` behind — versions are immutable, which means
|
|
10
|
+
they must be right the moment they appear.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
|
|
21
|
+
|
|
22
|
+
from acumen.config import Config
|
|
23
|
+
from acumen.env import AuthMode, Target, build_agent_env
|
|
24
|
+
from acumen.logs import LiveLog
|
|
25
|
+
from acumen.procs import label_env, reap
|
|
26
|
+
from acumen.prompts import draft_prompt
|
|
27
|
+
from acumen.skills import (
|
|
28
|
+
SKILL_FILE,
|
|
29
|
+
Skill,
|
|
30
|
+
SkillError,
|
|
31
|
+
load_skill,
|
|
32
|
+
next_version,
|
|
33
|
+
skill_dir,
|
|
34
|
+
write_meta,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DraftError(RuntimeError):
|
|
39
|
+
"""Raised when a skill could not be drafted."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DraftResult:
|
|
44
|
+
"""A drafted skill version and what it cost to produce."""
|
|
45
|
+
|
|
46
|
+
skill: Skill
|
|
47
|
+
cost_usd: float
|
|
48
|
+
turns: int
|
|
49
|
+
#: Live log paths for this run, when a :class:`LiveLog` was attached.
|
|
50
|
+
log_jsonl: Path | None = None
|
|
51
|
+
log_html: Path | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_staged(staging: Path, skill_name: str) -> None:
|
|
55
|
+
"""Fail loudly if the agent's output isn't a usable skill, before it becomes a version."""
|
|
56
|
+
if not (staging / SKILL_FILE).is_file():
|
|
57
|
+
raise DraftError(
|
|
58
|
+
f"the drafting agent did not write {SKILL_FILE} — nothing to promote. Inspect the run log or the prompt."
|
|
59
|
+
)
|
|
60
|
+
# load_skill enforces the frontmatter contract (name matches, description present).
|
|
61
|
+
try:
|
|
62
|
+
load_skill(staging.parent, staging.name, expect_name=skill_name)
|
|
63
|
+
except SkillError as err:
|
|
64
|
+
raise DraftError(f"the drafted skill is not valid: {err}") from err
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def draft_skill(
|
|
68
|
+
*,
|
|
69
|
+
cfg: Config,
|
|
70
|
+
target: Target,
|
|
71
|
+
skills_root: Path,
|
|
72
|
+
auth_mode: AuthMode = "session",
|
|
73
|
+
model: str | None = None,
|
|
74
|
+
max_turns: int | None = None,
|
|
75
|
+
max_usd: float | None = None,
|
|
76
|
+
rationale: str = "initial draft",
|
|
77
|
+
feedback: str | None = None,
|
|
78
|
+
log: LiveLog | None = None,
|
|
79
|
+
) -> DraftResult:
|
|
80
|
+
"""Draft a new skill version from the target package's source.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
cfg
|
|
85
|
+
The pass config; supplies ``skill_name`` and ``draft_model``.
|
|
86
|
+
target
|
|
87
|
+
The prepared target, supplying the source checkout and interpreter.
|
|
88
|
+
skills_root
|
|
89
|
+
The ``skills/`` root. The new version is the next unused one.
|
|
90
|
+
auth_mode
|
|
91
|
+
Which credential the drafting agent authenticates with — ``"session"`` (the Claude
|
|
92
|
+
subscription) or ``"api"`` (see :func:`acumen.env.build_agent_env`).
|
|
93
|
+
model
|
|
94
|
+
Override for the drafting model; defaults to the config's.
|
|
95
|
+
max_turns, max_usd
|
|
96
|
+
Turn and budget caps for the drafting agent. **Unbounded by default** — the config's
|
|
97
|
+
``max_turns``/``max_usd`` cap benchmark agents only; pass explicit caps to bound the
|
|
98
|
+
drafter.
|
|
99
|
+
rationale
|
|
100
|
+
Recorded in ``meta.json`` as why this version exists.
|
|
101
|
+
feedback
|
|
102
|
+
Optional maintainer guidance, injected into the draft prompt as a subordinated block
|
|
103
|
+
and recorded in ``meta.json`` as provenance.
|
|
104
|
+
log
|
|
105
|
+
A :class:`LiveLog` to stream the agent's messages to and render an HTML log from.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
The drafted skill, loaded and validated.
|
|
110
|
+
"""
|
|
111
|
+
version = next_version(skills_root)
|
|
112
|
+
dest = skill_dir(skills_root, version)
|
|
113
|
+
if dest.exists():
|
|
114
|
+
raise DraftError(f"{dest} already exists — skill versions are immutable and never overwritten")
|
|
115
|
+
|
|
116
|
+
holder = Path(tempfile.mkdtemp(prefix="acumen-draft-"))
|
|
117
|
+
try:
|
|
118
|
+
# The staging dir is named for the version so load_skill can validate it in place.
|
|
119
|
+
work = holder / "work"
|
|
120
|
+
staging = work / version
|
|
121
|
+
home = holder / "home"
|
|
122
|
+
config_dir = home / ".claude"
|
|
123
|
+
for path in (staging, home, config_dir, home / "tmp"):
|
|
124
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
# Marks the agent's processes so the teardown below can find what it 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=cfg.env_passthrough,
|
|
134
|
+
),
|
|
135
|
+
holder,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
prompt = draft_prompt(
|
|
139
|
+
package=target.pkg_name,
|
|
140
|
+
version=target.pkg_version,
|
|
141
|
+
src=target.src_dir,
|
|
142
|
+
python=target.python,
|
|
143
|
+
out=staging,
|
|
144
|
+
skill_name=cfg.skill_name,
|
|
145
|
+
feedback=feedback,
|
|
146
|
+
)
|
|
147
|
+
options = ClaudeAgentOptions(
|
|
148
|
+
cwd=str(work),
|
|
149
|
+
env=env,
|
|
150
|
+
model=model or cfg.draft_model,
|
|
151
|
+
# No default turn or budget cap: only bound the agent if the caller asked. The
|
|
152
|
+
# config's ``max_turns``/``max_usd`` cap benchmark agents only.
|
|
153
|
+
max_turns=max_turns,
|
|
154
|
+
max_budget_usd=max_usd,
|
|
155
|
+
# The drafter reads the target source; benchmark agents never do.
|
|
156
|
+
add_dirs=[str(target.src_dir)],
|
|
157
|
+
setting_sources=["project"],
|
|
158
|
+
permission_mode="bypassPermissions",
|
|
159
|
+
system_prompt={"type": "preset", "preset": "claude_code"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
result: ResultMessage | None = None
|
|
163
|
+
agent_error: Exception | None = None
|
|
164
|
+
try:
|
|
165
|
+
async for message in query(prompt=prompt, options=options):
|
|
166
|
+
if log is not None:
|
|
167
|
+
log.append(message)
|
|
168
|
+
if isinstance(message, ResultMessage):
|
|
169
|
+
result = message
|
|
170
|
+
except Exception as err: # noqa: BLE001 - a failed draft is an error to report, re-raised below
|
|
171
|
+
agent_error = err
|
|
172
|
+
finally:
|
|
173
|
+
# Render the HTML log while the throwaway config dir still holds the native
|
|
174
|
+
# transcript — in a finally so an aborted run (the SDK raises on a cap breach,
|
|
175
|
+
# after yielding the result) is still inspectable.
|
|
176
|
+
if log is not None:
|
|
177
|
+
log.finalize(config_dir=config_dir, work_dir=work, result=result)
|
|
178
|
+
|
|
179
|
+
if agent_error is not None:
|
|
180
|
+
raise DraftError(f"the drafting agent failed: {type(agent_error).__name__}: {agent_error}") from agent_error
|
|
181
|
+
if result is None:
|
|
182
|
+
raise DraftError("the drafting agent produced no result message")
|
|
183
|
+
if result.is_error:
|
|
184
|
+
raise DraftError(f"the drafting agent errored: {result.subtype} {result.errors or ''}".strip())
|
|
185
|
+
|
|
186
|
+
_validate_staged(staging, cfg.skill_name)
|
|
187
|
+
|
|
188
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
shutil.copytree(staging, dest)
|
|
190
|
+
write_meta(dest, parent=None, rationale=rationale, feedback=feedback)
|
|
191
|
+
skill = load_skill(skills_root, version, expect_name=cfg.skill_name)
|
|
192
|
+
return DraftResult(
|
|
193
|
+
skill=skill,
|
|
194
|
+
cost_usd=result.total_cost_usd or 0.0,
|
|
195
|
+
turns=result.num_turns,
|
|
196
|
+
log_jsonl=log.jsonl_path if log is not None else None,
|
|
197
|
+
log_html=log.html_path if log is not None and log.html_rendered else None,
|
|
198
|
+
)
|
|
199
|
+
finally:
|
|
200
|
+
# Kill anything the agent left running before removing the directory it runs in.
|
|
201
|
+
reap(holder)
|
|
202
|
+
shutil.rmtree(holder, ignore_errors=True)
|