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/logs.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Live agent logs for the meta-agents.
|
|
2
|
+
|
|
3
|
+
``draft``/``improve``/``tasks``/``ship`` each spin up one long, autonomous agent whose transcript
|
|
4
|
+
today is written into a throwaway ``CLAUDE_CONFIG_DIR`` and ``rmtree``'d on return — a black box.
|
|
5
|
+
:class:`LiveLog` makes each run observable:
|
|
6
|
+
|
|
7
|
+
* **A live JSONL**, appended and flushed *one normalized event per SDK message, during the run*,
|
|
8
|
+
at ``logs/acumen-<command>-<datetime>.jsonl``. This is the contract for a master agent watching
|
|
9
|
+
a run by **reading the file** on its own schedule — never a flood of the command's stdout into
|
|
10
|
+
its context ("print, don't read"). The feed is deliberately compact: it does **not** inline huge
|
|
11
|
+
tool results (file dumps, command output), only their status and size.
|
|
12
|
+
* **A terminal mirror** when ``--stream`` is on (off by default): each turn's assistant text and a
|
|
13
|
+
one-line-per-tool-call summary printed live for a human watching.
|
|
14
|
+
* **An HTML log** rendered at the end from the SDK-native transcript (:func:`finalize`), via the
|
|
15
|
+
same ``claude-code-log`` path the benchmark runner uses. The rich post-hoc artifact; the JSONL
|
|
16
|
+
is the lightweight live feed.
|
|
17
|
+
|
|
18
|
+
The class is stateful (it correlates tool results back to the tool that produced them) but has no
|
|
19
|
+
agent dependency, so it is unit-testable on a synthetic message sequence — like ``cli._Progress``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, TextIO
|
|
29
|
+
|
|
30
|
+
from claude_agent_sdk import (
|
|
31
|
+
AssistantMessage,
|
|
32
|
+
ResultMessage,
|
|
33
|
+
SystemMessage,
|
|
34
|
+
TextBlock,
|
|
35
|
+
ThinkingBlock,
|
|
36
|
+
ToolResultBlock,
|
|
37
|
+
ToolUseBlock,
|
|
38
|
+
UserMessage,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from acumen.transcript import locate_transcript, render_transcript
|
|
42
|
+
|
|
43
|
+
#: tool_input keys, in priority order, whose value best summarizes a tool call in one line.
|
|
44
|
+
_SUMMARY_KEYS = ("command", "file_path", "path", "notebook_path", "pattern", "url", "query", "description", "prompt")
|
|
45
|
+
|
|
46
|
+
#: Thinking blocks can be huge; the live feed keeps only a short preview of them.
|
|
47
|
+
_THINKING_CAP = 240
|
|
48
|
+
#: How long a one-line tool-input summary may get before it is truncated.
|
|
49
|
+
_SUMMARY_CAP = 160
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _truncate(text: str, cap: int) -> str:
|
|
53
|
+
text = text.strip()
|
|
54
|
+
return text if len(text) <= cap else text[:cap] + "…"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tool_summary(tool_input: Any) -> str:
|
|
58
|
+
"""A one-line summary of a tool call's input — the salient argument, whitespace-collapsed."""
|
|
59
|
+
if not isinstance(tool_input, dict):
|
|
60
|
+
return ""
|
|
61
|
+
for key in _SUMMARY_KEYS:
|
|
62
|
+
value = tool_input.get(key)
|
|
63
|
+
if isinstance(value, str) and value.strip():
|
|
64
|
+
return _truncate(" ".join(value.split()), _SUMMARY_CAP)
|
|
65
|
+
parts = [f"{key}={tool_input[key]!r}" for key in list(tool_input)[:3]]
|
|
66
|
+
return _truncate(", ".join(parts), _SUMMARY_CAP)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _content_len(content: Any) -> int:
|
|
70
|
+
"""Character length of a tool result's content — recorded instead of inlining the content."""
|
|
71
|
+
if isinstance(content, str):
|
|
72
|
+
return len(content)
|
|
73
|
+
if isinstance(content, list):
|
|
74
|
+
total = 0
|
|
75
|
+
for block in content:
|
|
76
|
+
if isinstance(block, dict):
|
|
77
|
+
total += len(str(block.get("text", "")))
|
|
78
|
+
else:
|
|
79
|
+
total += len(str(block))
|
|
80
|
+
return total
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LiveLog:
|
|
85
|
+
"""A live, append-only JSONL log of one meta-agent run, with optional terminal streaming.
|
|
86
|
+
|
|
87
|
+
Open one on ``logs/acumen-<command>-<datetime>.jsonl`` with :meth:`open`, feed every SDK
|
|
88
|
+
message to :meth:`append` inside the ``async for`` loop, and call :meth:`finalize` once the
|
|
89
|
+
run is done (before the throwaway config dir is deleted) to render the HTML log. Close it to
|
|
90
|
+
flush and release the file.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
jsonl_path: Path,
|
|
96
|
+
*,
|
|
97
|
+
stream: bool = False,
|
|
98
|
+
echo: Callable[[str], None] = print,
|
|
99
|
+
clock: Callable[[], str] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.jsonl_path = Path(jsonl_path)
|
|
102
|
+
self.html_path = self.jsonl_path.with_suffix(".html")
|
|
103
|
+
self.stream = stream
|
|
104
|
+
self.html_rendered = False
|
|
105
|
+
self.n_events = 0
|
|
106
|
+
self._echo = echo
|
|
107
|
+
self._clock = clock or (lambda: datetime.now().isoformat(timespec="seconds"))
|
|
108
|
+
self._tool_names: dict[str, str] = {}
|
|
109
|
+
self.jsonl_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
self._fh: TextIO = self.jsonl_path.open("w", encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def open(
|
|
114
|
+
cls,
|
|
115
|
+
log_dir: Path,
|
|
116
|
+
command: str,
|
|
117
|
+
*,
|
|
118
|
+
stream: bool = False,
|
|
119
|
+
now: datetime | None = None,
|
|
120
|
+
echo: Callable[[str], None] = print,
|
|
121
|
+
) -> LiveLog:
|
|
122
|
+
"""Open a log for ``command`` under ``log_dir`` with the fixed filename pattern.
|
|
123
|
+
|
|
124
|
+
The filename is ``acumen-<command>-<YYYYMMDD-HHMMSS>.jsonl``; the directory is the only
|
|
125
|
+
thing a caller overrides.
|
|
126
|
+
"""
|
|
127
|
+
stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
|
|
128
|
+
return cls(Path(log_dir) / f"acumen-{command}-{stamp}.jsonl", stream=stream, echo=echo)
|
|
129
|
+
|
|
130
|
+
def append(self, message: Any) -> None:
|
|
131
|
+
"""Normalize one SDK message to a compact event, append it (flushed), and maybe echo it."""
|
|
132
|
+
event = self._normalize(message)
|
|
133
|
+
if event is None:
|
|
134
|
+
return
|
|
135
|
+
record = {"ts": self._clock(), **event}
|
|
136
|
+
self._fh.write(json.dumps(record) + "\n")
|
|
137
|
+
self._fh.flush()
|
|
138
|
+
self.n_events += 1
|
|
139
|
+
if self.stream:
|
|
140
|
+
for line in self._render_lines(record):
|
|
141
|
+
self._echo(line)
|
|
142
|
+
|
|
143
|
+
def finalize(self, *, config_dir: Path, work_dir: Path, result: ResultMessage | None) -> bool:
|
|
144
|
+
"""Locate the run's SDK-native transcript and render the HTML log from it.
|
|
145
|
+
|
|
146
|
+
Call before the throwaway config dir is removed — the native transcript still lives under
|
|
147
|
+
it at this point. A no-op that returns ``False`` if the run produced no result.
|
|
148
|
+
"""
|
|
149
|
+
if result is None:
|
|
150
|
+
return False
|
|
151
|
+
native = locate_transcript(config_dir, work_dir, result.session_id)
|
|
152
|
+
if native is None or not native.is_file():
|
|
153
|
+
return False
|
|
154
|
+
self.html_rendered = render_transcript(native, self.html_path)
|
|
155
|
+
return self.html_rendered
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
"""Flush and release the underlying JSONL file. Idempotent."""
|
|
159
|
+
if not self._fh.closed:
|
|
160
|
+
self._fh.close()
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> LiveLog:
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *exc: object) -> None:
|
|
166
|
+
self.close()
|
|
167
|
+
|
|
168
|
+
# ── Normalization ──────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def _normalize(self, message: Any) -> dict[str, Any] | None:
|
|
171
|
+
if isinstance(message, AssistantMessage):
|
|
172
|
+
return self._assistant_event(message)
|
|
173
|
+
if isinstance(message, UserMessage):
|
|
174
|
+
return self._user_event(message)
|
|
175
|
+
if isinstance(message, SystemMessage):
|
|
176
|
+
return {"type": "system", "subtype": message.subtype}
|
|
177
|
+
if isinstance(message, ResultMessage):
|
|
178
|
+
return {
|
|
179
|
+
"type": "result",
|
|
180
|
+
"is_error": bool(message.is_error),
|
|
181
|
+
"subtype": message.subtype,
|
|
182
|
+
"turns": message.num_turns,
|
|
183
|
+
"cost_usd": round(message.total_cost_usd or 0.0, 4),
|
|
184
|
+
"duration_s": round((message.duration_ms or 0) / 1000, 2),
|
|
185
|
+
"session_id": message.session_id,
|
|
186
|
+
}
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def _assistant_event(self, message: AssistantMessage) -> dict[str, Any]:
|
|
190
|
+
text_parts: list[str] = []
|
|
191
|
+
thinking_parts: list[str] = []
|
|
192
|
+
tools: list[dict[str, Any]] = []
|
|
193
|
+
for block in message.content:
|
|
194
|
+
if isinstance(block, TextBlock):
|
|
195
|
+
if block.text.strip():
|
|
196
|
+
text_parts.append(block.text)
|
|
197
|
+
elif isinstance(block, ThinkingBlock):
|
|
198
|
+
thinking_parts.append(block.thinking)
|
|
199
|
+
elif isinstance(block, ToolUseBlock):
|
|
200
|
+
self._tool_names[block.id] = block.name
|
|
201
|
+
tools.append({"id": block.id, "name": block.name, "summary": _tool_summary(block.input)})
|
|
202
|
+
event: dict[str, Any] = {"type": "assistant"}
|
|
203
|
+
if message.model:
|
|
204
|
+
event["model"] = message.model
|
|
205
|
+
text = "\n".join(text_parts).strip()
|
|
206
|
+
if text:
|
|
207
|
+
event["text"] = text
|
|
208
|
+
if thinking_parts:
|
|
209
|
+
event["thinking"] = _truncate("\n".join(thinking_parts), _THINKING_CAP)
|
|
210
|
+
if tools:
|
|
211
|
+
event["tools"] = tools
|
|
212
|
+
return event
|
|
213
|
+
|
|
214
|
+
def _user_event(self, message: UserMessage) -> dict[str, Any] | None:
|
|
215
|
+
content = message.content
|
|
216
|
+
if not isinstance(content, list):
|
|
217
|
+
return None # a plain user turn (our own prompt) — nothing worth an event
|
|
218
|
+
results: list[dict[str, Any]] = []
|
|
219
|
+
for block in content:
|
|
220
|
+
if isinstance(block, ToolResultBlock):
|
|
221
|
+
results.append(
|
|
222
|
+
{
|
|
223
|
+
"tool_use_id": block.tool_use_id,
|
|
224
|
+
"name": self._tool_names.get(block.tool_use_id),
|
|
225
|
+
"status": "error" if block.is_error else "ok",
|
|
226
|
+
"chars": _content_len(block.content),
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
return {"type": "tool_result", "results": results} if results else None
|
|
230
|
+
|
|
231
|
+
# ── Terminal mirror (--stream) ─────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def _render_lines(self, event: dict[str, Any]) -> list[str]:
|
|
234
|
+
kind = event.get("type")
|
|
235
|
+
lines: list[str] = []
|
|
236
|
+
if kind == "assistant":
|
|
237
|
+
if event.get("thinking"):
|
|
238
|
+
lines.append(f" · thinking: {event['thinking']}")
|
|
239
|
+
if event.get("text"):
|
|
240
|
+
lines.append(str(event["text"]).strip())
|
|
241
|
+
for tool in event.get("tools", []):
|
|
242
|
+
summary = tool.get("summary") or ""
|
|
243
|
+
lines.append(f" → {tool['name']}: {summary}".rstrip())
|
|
244
|
+
elif kind == "tool_result":
|
|
245
|
+
for res in event["results"]:
|
|
246
|
+
mark = "✗" if res["status"] == "error" else "✓"
|
|
247
|
+
name = res.get("name") or "tool"
|
|
248
|
+
lines.append(f" {mark} {name} ({res['chars']} chars)")
|
|
249
|
+
elif kind == "result":
|
|
250
|
+
mark = "✗ error" if event["is_error"] else "● done"
|
|
251
|
+
lines.append(
|
|
252
|
+
f"{mark}: {event['subtype']} · {event['turns']} turns · "
|
|
253
|
+
f"${event['cost_usd']:.2f} · {event['duration_s']}s"
|
|
254
|
+
)
|
|
255
|
+
return lines
|
acumen/paths.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Run-directory layout — the single source of truth for path construction.
|
|
2
|
+
|
|
3
|
+
The layout is ``runs/{arm}/{split}/{model}/{task_id}/rep_{n}/`` and is consumed by the
|
|
4
|
+
runner, the resume check, the improver's allow/deny list, and the report. Anything that
|
|
5
|
+
needs to know where a run lives asks this module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
Split = Literal["train", "test"]
|
|
16
|
+
|
|
17
|
+
SPLITS: tuple[Split, ...] = ("train", "test")
|
|
18
|
+
|
|
19
|
+
NOSKILL_ARM = "noskill"
|
|
20
|
+
|
|
21
|
+
ANSWER_FILE = "answer.md"
|
|
22
|
+
SCRIPT_FILE = "script.py"
|
|
23
|
+
TRANSCRIPT_JSONL = "transcript.jsonl"
|
|
24
|
+
TRANSCRIPT_HTML = "transcript.html"
|
|
25
|
+
RESULT_FILE = "result.json"
|
|
26
|
+
|
|
27
|
+
#: Every file a completed run leaf is expected to contain.
|
|
28
|
+
RUN_FILES = (ANSWER_FILE, SCRIPT_FILE, TRANSCRIPT_JSONL, TRANSCRIPT_HTML, RESULT_FILE)
|
|
29
|
+
|
|
30
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
31
|
+
_ARM_RE = re.compile(r"^skill_v(\d+)$")
|
|
32
|
+
_REP_RE = re.compile(r"^rep_(\d+)$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PathError(ValueError):
|
|
36
|
+
"""Raised when a path cannot be constructed or parsed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def slugify(text: str) -> str:
|
|
40
|
+
"""Make ``text`` safe to use as a single path component.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
text
|
|
45
|
+
Arbitrary text, e.g. a model name such as ``anthropic/claude-opus-5``.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
A string containing only ``[A-Za-z0-9._-]``.
|
|
50
|
+
"""
|
|
51
|
+
slug = _UNSAFE.sub("-", text).strip("-")
|
|
52
|
+
if not slug or slug in {".", ".."}:
|
|
53
|
+
raise PathError(f"{text!r} has no filesystem-safe representation")
|
|
54
|
+
return slug
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_safe_component(text: str) -> bool:
|
|
58
|
+
"""Return whether ``text`` is already usable as a path component verbatim."""
|
|
59
|
+
return bool(text) and text not in {".", ".."} and not _UNSAFE.search(text)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def arm_name(skill: str | None) -> str:
|
|
63
|
+
"""Return the run-directory arm name for a skill version.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
skill
|
|
68
|
+
A skill version such as ``"v1"``, or ``None`` for the baseline arm.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
``"noskill"`` for the baseline, otherwise ``"skill_v1"``-style.
|
|
73
|
+
"""
|
|
74
|
+
if skill is None:
|
|
75
|
+
return NOSKILL_ARM
|
|
76
|
+
version = skill[1:] if skill.startswith("v") else skill
|
|
77
|
+
if not version.isdigit():
|
|
78
|
+
raise PathError(f"skill version must look like 'v1', got {skill!r}")
|
|
79
|
+
return f"skill_v{int(version)}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def skill_from_arm(arm: str) -> str | None:
|
|
83
|
+
"""Invert :func:`arm_name` — return the skill version an arm refers to."""
|
|
84
|
+
if arm == NOSKILL_ARM:
|
|
85
|
+
return None
|
|
86
|
+
match = _ARM_RE.match(arm)
|
|
87
|
+
if not match:
|
|
88
|
+
raise PathError(f"not an arm name: {arm!r}")
|
|
89
|
+
return f"v{int(match.group(1))}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class RunKey:
|
|
94
|
+
"""Identifies exactly one benchmark run — one leaf of the run tree."""
|
|
95
|
+
|
|
96
|
+
arm: str
|
|
97
|
+
split: Split
|
|
98
|
+
model: str
|
|
99
|
+
task_id: str
|
|
100
|
+
rep: int
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def skill(self) -> str | None:
|
|
104
|
+
"""The skill version this run exercises, or ``None`` for the baseline."""
|
|
105
|
+
return skill_from_arm(self.arm)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_dir(runs_root: Path, key: RunKey) -> Path:
|
|
109
|
+
"""Build the directory for a single run.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
runs_root
|
|
114
|
+
The ``runs/`` root directory.
|
|
115
|
+
key
|
|
116
|
+
Which run to locate.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
``runs_root/{arm}/{split}/{model}/{task_id}/rep_{n}``.
|
|
121
|
+
"""
|
|
122
|
+
if key.split not in SPLITS:
|
|
123
|
+
raise PathError(f"split must be one of {SPLITS}, got {key.split!r}")
|
|
124
|
+
if key.rep < 1:
|
|
125
|
+
raise PathError(f"rep must be >= 1, got {key.rep}")
|
|
126
|
+
if not is_safe_component(key.task_id):
|
|
127
|
+
raise PathError(f"task id {key.task_id!r} is not filesystem-safe")
|
|
128
|
+
return runs_root / key.arm / key.split / slugify(key.model) / key.task_id / f"rep_{key.rep}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_run_dir(runs_root: Path, path: Path) -> RunKey:
|
|
132
|
+
"""Recover a :class:`RunKey` from a run directory.
|
|
133
|
+
|
|
134
|
+
The model is returned in its slugified form, which is what the path carries; the
|
|
135
|
+
canonical model name lives in ``result.json``.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
rel = path.resolve().relative_to(runs_root.resolve())
|
|
139
|
+
except ValueError as err:
|
|
140
|
+
raise PathError(f"{path} is not under {runs_root}") from err
|
|
141
|
+
parts = rel.parts
|
|
142
|
+
if len(parts) != 5:
|
|
143
|
+
raise PathError(f"expected {runs_root}/arm/split/model/task/rep_n, got {rel}")
|
|
144
|
+
arm, split, model, task_id, rep = parts
|
|
145
|
+
skill_from_arm(arm) # validates the arm name
|
|
146
|
+
if split not in SPLITS:
|
|
147
|
+
raise PathError(f"split must be one of {SPLITS}, got {split!r}")
|
|
148
|
+
match = _REP_RE.match(rep)
|
|
149
|
+
if not match:
|
|
150
|
+
raise PathError(f"expected 'rep_<n>', got {rep!r}")
|
|
151
|
+
return RunKey(arm=arm, split=split, model=model, task_id=task_id, rep=int(match.group(1))) # type: ignore[arg-type]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_complete(directory: Path) -> bool:
|
|
155
|
+
"""Return whether a run directory holds a complete run.
|
|
156
|
+
|
|
157
|
+
A readable ``result.json`` is what marks a run done — this is the check that makes
|
|
158
|
+
resuming a pass work.
|
|
159
|
+
"""
|
|
160
|
+
result = directory / RESULT_FILE
|
|
161
|
+
try:
|
|
162
|
+
return result.is_file() and result.stat().st_size > 0
|
|
163
|
+
except OSError:
|
|
164
|
+
return False
|
acumen/procs.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Killing the processes an agent leaves behind.
|
|
2
|
+
|
|
3
|
+
An agent is a ``claude`` CLI subprocess, and that CLI spawns processes of its own: every
|
|
4
|
+
Bash tool call is a shell, and a shell can start a pytest, a fit that runs for an hour, or
|
|
5
|
+
something backgrounded with ``&``. The SDK terminates the CLI and nothing else — so does a
|
|
6
|
+
crash, a cap breach, or a Ctrl-C — and the descendants survive. They keep burning CPU
|
|
7
|
+
alongside the runs still going, and they keep writing into a directory that is about to be
|
|
8
|
+
deleted.
|
|
9
|
+
|
|
10
|
+
The parent link cannot find them, on any platform. On Unix an orphan is reparented to init,
|
|
11
|
+
so the tree that identified it is gone; on Windows it is not reparented at all and its
|
|
12
|
+
recorded parent id dangles, free to be reused by an unrelated process. What survives in
|
|
13
|
+
both cases is the environment: :func:`label_env` stamps every agent with :data:`RUN_MARKER`,
|
|
14
|
+
set to that run's private temp directory, and a process inherits its environment from
|
|
15
|
+
whatever started it. So the marker identifies a descendant even after the parent is gone,
|
|
16
|
+
the process has renamed itself, or it has changed directory. :func:`reap` finds everything
|
|
17
|
+
carrying a given run's marker and kills it; the sandbox and the meta-agent staging dirs call
|
|
18
|
+
it on the way out, before their directory is removed, on every exit path including
|
|
19
|
+
cancellation.
|
|
20
|
+
|
|
21
|
+
The marker is what makes this exact rather than a guess at which paths a run touches. The
|
|
22
|
+
benchmark sandboxes do put their throwaway ``HOME`` and ``TMPDIR`` under the same directory,
|
|
23
|
+
but ``ship``'s agent deliberately runs in the operator's real environment and real checkout,
|
|
24
|
+
where no such path appears — one explicit variable covers both.
|
|
25
|
+
|
|
26
|
+
Reading *another* process's environment is the one step with no portable stdlib spelling:
|
|
27
|
+
it is a file read on Linux, a ``KERN_PROCARGS2`` sysctl on macOS, and a ``ReadProcessMemory``
|
|
28
|
+
walk of the PEB on Windows. ``psutil`` implements all three natively, which is why it is a
|
|
29
|
+
dependency. :func:`supported` reports whether the running platform is one of them.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Callable, Iterator
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import psutil
|
|
38
|
+
|
|
39
|
+
#: Environment variable naming the run a process belongs to, set to the run's temp directory.
|
|
40
|
+
#: Stamped on every agent by :func:`label_env` and inherited by everything the agent spawns.
|
|
41
|
+
#: Uppercase deliberately: Windows environment keys are case-insensitive and psutil upper-cases
|
|
42
|
+
#: them when reading a process block, so an all-caps name is the one spelling that round-trips
|
|
43
|
+
#: identically on every platform.
|
|
44
|
+
RUN_MARKER = "ACUMEN_RUN"
|
|
45
|
+
|
|
46
|
+
#: How long to let a signalled process exit before escalating to a hard kill. An upper bound,
|
|
47
|
+
#: not a fixed pause — the wait ends the moment the processes are gone, which is the common
|
|
48
|
+
#: case. It still needs to stay modest: the callers are synchronous teardown paths inside
|
|
49
|
+
#: async runs, so this blocks the event loop and every other run on it.
|
|
50
|
+
GRACE_S = 2.0
|
|
51
|
+
|
|
52
|
+
#: Raised by psutil when a process is gone, is another user's, or is unreadable for a
|
|
53
|
+
#: platform-specific reason (on Windows a 32-bit interpreter cannot read a 64-bit process's
|
|
54
|
+
#: environment). Every one of these means "not identifiable as ours", never "abort the scan".
|
|
55
|
+
_UNREADABLE = (psutil.Error, OSError, ValueError)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def supported() -> bool:
|
|
59
|
+
"""Whether orphan reaping works on this platform.
|
|
60
|
+
|
|
61
|
+
True on Linux, macOS and Windows. False only where psutil cannot read another process's
|
|
62
|
+
environment, in which case :func:`reap` does nothing and orphans are left alone.
|
|
63
|
+
"""
|
|
64
|
+
return hasattr(psutil.Process, "environ")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def label_env(env: dict[str, str], holder: Path) -> dict[str, str]:
|
|
68
|
+
"""Stamp an agent's environment with the run it belongs to, and return it.
|
|
69
|
+
|
|
70
|
+
Call this on the mapping handed to the SDK, with the same ``holder`` later passed to
|
|
71
|
+
:func:`reap`. Without the stamp a run's orphans are unidentifiable and :func:`reap` has
|
|
72
|
+
nothing to match on.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
env
|
|
77
|
+
The agent's environment, modified in place.
|
|
78
|
+
holder
|
|
79
|
+
The run's private temp directory, which is unique per run and so serves as its id.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
The same mapping, for chaining onto the call that built it.
|
|
84
|
+
"""
|
|
85
|
+
env[RUN_MARKER] = str(holder)
|
|
86
|
+
return env
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _protected() -> set[int]:
|
|
90
|
+
"""Return acumen's own pid and every ancestor of it.
|
|
91
|
+
|
|
92
|
+
A belt-and-braces guard. Matching is on a variable acumen itself never has in its
|
|
93
|
+
environment, so these should never come up as candidates — but the cost of a false
|
|
94
|
+
positive here is killing the process doing the killing, or the operator's shell.
|
|
95
|
+
"""
|
|
96
|
+
me = psutil.Process()
|
|
97
|
+
protected = {me.pid}
|
|
98
|
+
try:
|
|
99
|
+
protected.update(parent.pid for parent in me.parents())
|
|
100
|
+
except _UNREADABLE: # pragma: no cover - an ancestor exiting mid-walk
|
|
101
|
+
pass
|
|
102
|
+
return protected
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _labelled(proc: psutil.Process, holder: Path) -> bool:
|
|
106
|
+
"""Whether ``proc`` belongs to the run rooted at ``holder``.
|
|
107
|
+
|
|
108
|
+
The environment psutil reports is the one the process was *executed* with, which is why
|
|
109
|
+
the marker still identifies a descendant whose parent has since died.
|
|
110
|
+
|
|
111
|
+
``cwd`` is checked as a second signal, for the rare process started with a wiped
|
|
112
|
+
environment that is nonetheless running inside the run's directory. Path comparison is
|
|
113
|
+
case-insensitive on Windows, which :class:`~pathlib.Path` already handles.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
if proc.environ().get(RUN_MARKER) == str(holder):
|
|
117
|
+
return True
|
|
118
|
+
except _UNREADABLE:
|
|
119
|
+
pass
|
|
120
|
+
try:
|
|
121
|
+
cwd = proc.cwd()
|
|
122
|
+
except _UNREADABLE:
|
|
123
|
+
return False
|
|
124
|
+
return bool(cwd) and Path(cwd).is_relative_to(holder)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _survivor_procs(holder: Path) -> Iterator[psutil.Process]:
|
|
128
|
+
"""Yield live processes descended from the agent that ran in ``holder``."""
|
|
129
|
+
if not supported():
|
|
130
|
+
return
|
|
131
|
+
protected = _protected()
|
|
132
|
+
for proc in psutil.process_iter():
|
|
133
|
+
# Processes come and go mid-scan; one that vanished is simply not a survivor.
|
|
134
|
+
if proc.pid not in protected and _labelled(proc, holder):
|
|
135
|
+
yield proc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def survivors(holder: Path) -> list[int]:
|
|
139
|
+
"""Return the pids of every live process descended from the agent that ran in ``holder``.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
holder
|
|
144
|
+
The run's temp directory, the same one passed to :func:`label_env`.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
The matching pids, excluding acumen and its ancestors. Empty on an unsupported platform.
|
|
149
|
+
"""
|
|
150
|
+
return [proc.pid for proc in _survivor_procs(holder)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _quietly(action: Callable[[], None]) -> None:
|
|
154
|
+
"""Run a kill that may lose its race with the process exiting on its own."""
|
|
155
|
+
try:
|
|
156
|
+
action()
|
|
157
|
+
except _UNREADABLE:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def reap(holder: Path, *, grace: float = GRACE_S) -> list[int]:
|
|
162
|
+
"""Kill everything the agent that ran in ``holder`` left behind.
|
|
163
|
+
|
|
164
|
+
Asks politely first (``SIGTERM``; on Windows, where there is no such signal, psutil
|
|
165
|
+
terminates outright), then kills whatever is still standing after ``grace``. The second
|
|
166
|
+
round re-scans rather than reusing the first round's list, so a process spawned while
|
|
167
|
+
the first signals were landing is caught too.
|
|
168
|
+
|
|
169
|
+
Safe to call when nothing leaked, when the agent exited cleanly, and on a platform
|
|
170
|
+
psutil cannot read — in each case it does nothing and returns an empty list.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
holder
|
|
175
|
+
The run's temp directory, as passed to :func:`survivors`.
|
|
176
|
+
grace
|
|
177
|
+
Seconds to wait before the hard kill. Blocks the caller, but only until the
|
|
178
|
+
signalled processes are actually gone.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
The pids that were signalled, for logging. An empty list means nothing was orphaned.
|
|
183
|
+
"""
|
|
184
|
+
doomed = list(_survivor_procs(holder))
|
|
185
|
+
if not doomed:
|
|
186
|
+
return []
|
|
187
|
+
for proc in doomed:
|
|
188
|
+
_quietly(proc.terminate)
|
|
189
|
+
# Waiting on a process that is our own direct child consumes its exit status, which
|
|
190
|
+
# would otherwise be the SDK's to collect. Harmless here: callers reap only once the
|
|
191
|
+
# SDK has closed its transport, so the agent process has already been waited on.
|
|
192
|
+
psutil.wait_procs(doomed, timeout=grace)
|
|
193
|
+
for proc in _survivor_procs(holder):
|
|
194
|
+
_quietly(proc.kill)
|
|
195
|
+
return [proc.pid for proc in doomed]
|