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