agentpace 0.9.2__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.
- agentpace/__init__.py +3 -0
- agentpace/__main__.py +8 -0
- agentpace/_bundled/DATA-LICENSE +121 -0
- agentpace/_bundled/example-plan.json +11 -0
- agentpace/_bundled/run.schema.json +100 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W40-2026-W45.jsonl +3219 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W44-2026-W45.jsonl +211 -0
- agentpace/_bundled/turn.schema.json +30 -0
- agentpace/adapters/__init__.py +31 -0
- agentpace/adapters/base.py +74 -0
- agentpace/adapters/claude_code.py +265 -0
- agentpace/adapters/cline.py +449 -0
- agentpace/adapters/codex.py +293 -0
- agentpace/adapters/gemini_cli.py +332 -0
- agentpace/adapters/opencode.py +397 -0
- agentpace/backtest.py +801 -0
- agentpace/cache.py +115 -0
- agentpace/charts.py +191 -0
- agentpace/claims.py +164 -0
- agentpace/classify.py +54 -0
- agentpace/cli.py +754 -0
- agentpace/estimate.py +694 -0
- agentpace/forecasts.py +284 -0
- agentpace/ingest.py +337 -0
- agentpace/paths.py +62 -0
- agentpace/pseudonym.py +51 -0
- agentpace/py.typed +0 -0
- agentpace/render.py +152 -0
- agentpace/report.py +502 -0
- agentpace/schema.py +131 -0
- agentpace/share.py +130 -0
- agentpace/site.py +133 -0
- agentpace/turns.py +282 -0
- agentpace/validate.py +115 -0
- agentpace-0.9.2.dist-info/METADATA +324 -0
- agentpace-0.9.2.dist-info/RECORD +39 -0
- agentpace-0.9.2.dist-info/WHEEL +4 -0
- agentpace-0.9.2.dist-info/entry_points.txt +2 -0
- agentpace-0.9.2.dist-info/licenses/LICENSE +21 -0
agentpace/forecasts.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""A local ledger of forecasts, so a user can later see how often their own estimates held.
|
|
2
|
+
|
|
3
|
+
`estimate --record` appends one forecast record to `~/.agentpace/forecasts.jsonl`. Scores are
|
|
4
|
+
appended later as separate records, never edited in place. The file is local only: `share` never
|
|
5
|
+
reads it. It may hold free text, such as task ids and a quoted human-scale timeline, but no file
|
|
6
|
+
paths, no data sources and nothing derived from the local secret.
|
|
7
|
+
|
|
8
|
+
A quote such as "3 weeks" is calendar time as someone said it. It is stored as given, and turned
|
|
9
|
+
into seconds only when it parses exactly, by fixed units. No other conversion is ever applied.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import statistics
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
Record = dict[str, Any]
|
|
26
|
+
|
|
27
|
+
FORECAST, SCORE = "forecast", "score"
|
|
28
|
+
_UNITS = {
|
|
29
|
+
"min": 60,
|
|
30
|
+
"minute": 60,
|
|
31
|
+
"minutes": 60,
|
|
32
|
+
"h": 3600,
|
|
33
|
+
"hour": 3600,
|
|
34
|
+
"hours": 3600,
|
|
35
|
+
"d": 86_400,
|
|
36
|
+
"day": 86_400,
|
|
37
|
+
"days": 86_400,
|
|
38
|
+
"w": 7 * 86_400,
|
|
39
|
+
"week": 7 * 86_400,
|
|
40
|
+
"weeks": 7 * 86_400,
|
|
41
|
+
"mo": 30 * 86_400,
|
|
42
|
+
"month": 30 * 86_400,
|
|
43
|
+
"months": 30 * 86_400,
|
|
44
|
+
}
|
|
45
|
+
_DURATION = re.compile(
|
|
46
|
+
r"^\s*(\d+(?:\.\d+)?)\s*(min|minutes?|h|hours?|d|days?|w|weeks?|mo|months?)\s*$",
|
|
47
|
+
re.IGNORECASE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_duration(text: str) -> int | None:
|
|
52
|
+
"""Seconds for an exact "<number> <unit>", by fixed units (1 d = 86,400 s, 1 w = 7 d,
|
|
53
|
+
1 mo = 30 d). Anything else is None, never a guess."""
|
|
54
|
+
match = _DURATION.match(text)
|
|
55
|
+
if match is None:
|
|
56
|
+
return None
|
|
57
|
+
seconds = float(match.group(1)) * _UNITS[match.group(2).lower()]
|
|
58
|
+
return round(seconds) if seconds >= 1 else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def utc_now() -> str:
|
|
62
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _canonical(record: Mapping[str, Any]) -> str:
|
|
66
|
+
return json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def record_id(record: Mapping[str, Any]) -> str:
|
|
70
|
+
"""Derived from the content and the time it was issued, so no random draw is needed."""
|
|
71
|
+
body = {key: value for key, value in record.items() if key != "id"}
|
|
72
|
+
return hashlib.sha256((record["issued_at"] + _canonical(body)).encode("utf-8")).hexdigest()[:8]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def forecast_record(
|
|
76
|
+
*,
|
|
77
|
+
issued_at: str,
|
|
78
|
+
version: str,
|
|
79
|
+
plan_document: Mapping[str, Any],
|
|
80
|
+
result: Mapping[str, Any],
|
|
81
|
+
quoted: str | None,
|
|
82
|
+
) -> Record:
|
|
83
|
+
"""`result` is the dict `render.plan_json` prints, so the ledger holds what the user saw."""
|
|
84
|
+
record: Record = {
|
|
85
|
+
"kind": FORECAST,
|
|
86
|
+
"issued_at": issued_at,
|
|
87
|
+
"version": version,
|
|
88
|
+
"method": result["method"],
|
|
89
|
+
"seed": result["seed"],
|
|
90
|
+
"draws": result["draws"],
|
|
91
|
+
"rows_used": result["rows_used"],
|
|
92
|
+
"rows_excluded": result["rows_excluded"],
|
|
93
|
+
"plan": plan_document,
|
|
94
|
+
"p50": result["p50"],
|
|
95
|
+
"p80": result["p80"],
|
|
96
|
+
"p90": result["p90"],
|
|
97
|
+
"tasks": [
|
|
98
|
+
{"id": t["id"], "p50": t["p50"], "p80": t["p80"], "p90": t["p90"], "cell": t["cell"]}
|
|
99
|
+
for t in result["tasks"]
|
|
100
|
+
],
|
|
101
|
+
"human": result["human"],
|
|
102
|
+
}
|
|
103
|
+
if quoted is not None:
|
|
104
|
+
record["quoted"] = quoted
|
|
105
|
+
seconds = parse_duration(quoted)
|
|
106
|
+
if seconds is not None:
|
|
107
|
+
record["quoted_s"] = seconds
|
|
108
|
+
record["id"] = record_id(record)
|
|
109
|
+
return record
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def append(path: Path, record: Mapping[str, Any]) -> None:
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
115
|
+
handle.write(_canonical(record) + "\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Ledger:
|
|
120
|
+
forecasts: dict[str, Record] = field(default_factory=dict)
|
|
121
|
+
scores: list[Record] = field(default_factory=list)
|
|
122
|
+
skipped: int = 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _valid(record: Any) -> bool:
|
|
126
|
+
if not isinstance(record, dict) or not isinstance(record.get("id"), str):
|
|
127
|
+
return False
|
|
128
|
+
if record.get("kind") == FORECAST:
|
|
129
|
+
return all(isinstance(record.get(key), (int, float)) for key in ("p50", "p80", "p90"))
|
|
130
|
+
if record.get("kind") == SCORE:
|
|
131
|
+
return isinstance(record.get("actual_s"), int)
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read(path: Path) -> Ledger:
|
|
136
|
+
"""Every valid record. A damaged or unknown line is skipped and counted, never fatal."""
|
|
137
|
+
ledger = Ledger()
|
|
138
|
+
if not path.exists():
|
|
139
|
+
return ledger
|
|
140
|
+
with path.open(encoding="utf-8") as handle:
|
|
141
|
+
for line in handle:
|
|
142
|
+
if not line.strip():
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
record = json.loads(line)
|
|
146
|
+
except ValueError:
|
|
147
|
+
record = None
|
|
148
|
+
if not _valid(record):
|
|
149
|
+
ledger.skipped += 1
|
|
150
|
+
elif record["kind"] == FORECAST:
|
|
151
|
+
ledger.forecasts[record["id"]] = record
|
|
152
|
+
else:
|
|
153
|
+
ledger.scores.append(record)
|
|
154
|
+
return ledger
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def latest_scores(scores: Sequence[Record]) -> dict[str, Record]:
|
|
158
|
+
"""The latest score per forecast wins, so a correction never needs an edit in place."""
|
|
159
|
+
latest: dict[str, Record] = {}
|
|
160
|
+
for score in scores:
|
|
161
|
+
latest[score["id"]] = score
|
|
162
|
+
return latest
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Scoring. A score compares a forecast with what happened. With a session, the actual is that
|
|
166
|
+
# session's span on its own clock: agent runs plus the gaps between them, each gap up to the idle
|
|
167
|
+
# cap. That is the same quantity the forecast predicts and the backtest scores. It is not wall
|
|
168
|
+
# clock, and no allowance for the human part is added to it. With `--actual`, the user attests the
|
|
169
|
+
# figure themselves.
|
|
170
|
+
|
|
171
|
+
MIN_RATED = 5
|
|
172
|
+
QUANTILE_KEYS = (("p50", 0.5), ("p80", 0.8), ("p90", 0.9))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ScoreRefused(ValueError):
|
|
176
|
+
"""A score that cannot be made honestly, with the reason as its message."""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_issued(text: str) -> float:
|
|
180
|
+
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def iso_week_of(epoch: float) -> str:
|
|
184
|
+
year, week, _ = datetime.fromtimestamp(epoch, tz=timezone.utc).isocalendar()
|
|
185
|
+
return f"{year}-W{week:02d}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def session_span(rows: Sequence[Mapping[str, Any]]) -> int:
|
|
189
|
+
"""`max(offset + duration) - min(offset)` on the session clock, as in the backtest."""
|
|
190
|
+
end = max(int(row["offset_s"]) + int(row["duration_s"]) for row in rows)
|
|
191
|
+
return max(1, end - min(int(row["offset_s"]) for row in rows))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _quote_closer(forecast: Mapping[str, Any], actual_s: int) -> bool | None:
|
|
195
|
+
"""Whether the quoted timeline was closer to the actual than the P50, by ratio."""
|
|
196
|
+
quoted = forecast.get("quoted_s")
|
|
197
|
+
if not isinstance(quoted, int) or quoted <= 0 or forecast["p50"] <= 0 or actual_s <= 0:
|
|
198
|
+
return None
|
|
199
|
+
return abs(math.log(quoted / actual_s)) < abs(math.log(forecast["p50"] / actual_s))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def score_record(
|
|
203
|
+
forecast: Mapping[str, Any],
|
|
204
|
+
*,
|
|
205
|
+
actual_s: int,
|
|
206
|
+
source: str,
|
|
207
|
+
verified: bool,
|
|
208
|
+
scored_at: str,
|
|
209
|
+
session: str | None = None,
|
|
210
|
+
) -> Record:
|
|
211
|
+
record: Record = {
|
|
212
|
+
"kind": SCORE,
|
|
213
|
+
"id": forecast["id"],
|
|
214
|
+
"scored_at": scored_at,
|
|
215
|
+
"actual_s": actual_s,
|
|
216
|
+
"source": source,
|
|
217
|
+
"verified": verified,
|
|
218
|
+
"quote_closer": _quote_closer(forecast, actual_s),
|
|
219
|
+
}
|
|
220
|
+
if session is not None:
|
|
221
|
+
record["session"] = session
|
|
222
|
+
return record
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_session(
|
|
226
|
+
forecast: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], first_start: float | None
|
|
227
|
+
) -> int:
|
|
228
|
+
"""The actual for a session score, or a refusal. A session scores only when it provably
|
|
229
|
+
started after the forecast was issued."""
|
|
230
|
+
if len(rows) < 2:
|
|
231
|
+
raise ScoreRefused(
|
|
232
|
+
"that session has fewer than 2 completed local runs; run `agentpace ingest`"
|
|
233
|
+
)
|
|
234
|
+
issued = parse_issued(forecast["issued_at"])
|
|
235
|
+
if min(row["week"] for row in rows) < iso_week_of(issued):
|
|
236
|
+
raise ScoreRefused("that session ran before the forecast was issued, so it cannot score it")
|
|
237
|
+
if first_start is None:
|
|
238
|
+
raise ScoreRefused(
|
|
239
|
+
"cannot verify this session started after the forecast; use --actual to attest it"
|
|
240
|
+
)
|
|
241
|
+
if first_start < issued:
|
|
242
|
+
raise ScoreRefused("that session started before the forecast was issued")
|
|
243
|
+
return session_span(rows)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _pinball(actual: float, predicted: float, q: float) -> float:
|
|
247
|
+
gap = actual - predicted
|
|
248
|
+
return max(q * gap, (q - 1) * gap)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True)
|
|
252
|
+
class Summary:
|
|
253
|
+
scored: int
|
|
254
|
+
attested: int
|
|
255
|
+
within: dict[str, float] | None
|
|
256
|
+
pinball: float | None
|
|
257
|
+
quotes: int
|
|
258
|
+
quote_closer: float | None
|
|
259
|
+
quote_ratio: float | None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def summarize(ledger: Ledger) -> Summary:
|
|
263
|
+
pairs = [
|
|
264
|
+
(ledger.forecasts[key], score)
|
|
265
|
+
for key, score in latest_scores(ledger.scores).items()
|
|
266
|
+
if key in ledger.forecasts
|
|
267
|
+
]
|
|
268
|
+
attested = sum(not score["verified"] for _, score in pairs)
|
|
269
|
+
within = pinball = None
|
|
270
|
+
if len(pairs) >= MIN_RATED:
|
|
271
|
+
within = {
|
|
272
|
+
key: sum(score["actual_s"] <= forecast[key] for forecast, score in pairs) / len(pairs)
|
|
273
|
+
for key, _ in QUANTILE_KEYS
|
|
274
|
+
}
|
|
275
|
+
pinball = sum(
|
|
276
|
+
sum(_pinball(score["actual_s"], forecast[key], q) for key, q in QUANTILE_KEYS)
|
|
277
|
+
for forecast, score in pairs
|
|
278
|
+
) / len(pairs)
|
|
279
|
+
quoted = [(f, s) for f, s in pairs if isinstance(f.get("quoted_s"), int)]
|
|
280
|
+
closer = ratio = None
|
|
281
|
+
if len(quoted) >= MIN_RATED:
|
|
282
|
+
closer = sum(bool(s["quote_closer"]) for _, s in quoted) / len(quoted)
|
|
283
|
+
ratio = statistics.median(f["quoted_s"] / s["actual_s"] for f, s in quoted)
|
|
284
|
+
return Summary(len(pairs), attested, within, pinball, len(quoted), closer, ratio)
|
agentpace/ingest.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Turn `RawRun` records into normalized rows, one code path for every harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from bisect import bisect_right
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
12
|
+
from contextlib import contextmanager, suppress
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from agentpace import classify, pseudonym, schema
|
|
18
|
+
from agentpace.adapters.base import Adapter, ParseStats, RawRun, Source
|
|
19
|
+
from agentpace.cache import RunCache
|
|
20
|
+
from agentpace.schema import Row
|
|
21
|
+
|
|
22
|
+
DEFAULT_IDLE_CAP_S = 900
|
|
23
|
+
_EFFORTS = frozenset(schema.enum("effort"))
|
|
24
|
+
|
|
25
|
+
_FAMILIES = (
|
|
26
|
+
re.compile(r"claude-(?:opus|sonnet|haiku|fable)-\d+(?:-\d{1,2})?(?!\d)"),
|
|
27
|
+
re.compile(r"claude-\d(?:-\d)?-(?:opus|sonnet|haiku)"),
|
|
28
|
+
re.compile(r"gpt-\d+(?:\.\d+)?(?:-[a-z]+)?"),
|
|
29
|
+
re.compile(r"gemini-\d(?:\.\d)?-[a-z]+(?:-lite)?"),
|
|
30
|
+
re.compile(r"(?<![a-z0-9])o\d(?:-[a-z]+)?(?![a-z0-9])"),
|
|
31
|
+
)
|
|
32
|
+
# Some providers, such as OpenRouter or Copilot, write a dotted Claude version,
|
|
33
|
+
# `claude-sonnet-4.5`, which the families above would cut to `claude-sonnet-4`. Rewrite only
|
|
34
|
+
# that shape to the dashed family, for every harness.
|
|
35
|
+
_DOTTED_CLAUDE = re.compile(r"(claude-(?:opus|sonnet|haiku|fable)-\d+)\.(\d{1,2})(?!\d)")
|
|
36
|
+
_DOTTED_CLAUDE_OLD = re.compile(r"(claude-\d)\.(\d)-")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _canonical_model(model: str) -> str:
|
|
40
|
+
return _DOTTED_CLAUDE_OLD.sub(r"\1-\2-", _DOTTED_CLAUDE.sub(r"\1-\2", model))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_model(raw: str) -> str:
|
|
44
|
+
"""Keep a public model family. ARNs and proxy names can leak account or customer names."""
|
|
45
|
+
lowered = _canonical_model(raw.lower())
|
|
46
|
+
for family in _FAMILIES:
|
|
47
|
+
match = family.search(lowered)
|
|
48
|
+
if match:
|
|
49
|
+
return match.group(0)
|
|
50
|
+
return "other"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def iso_week(epoch: float) -> str:
|
|
54
|
+
year, week, _ = datetime.fromtimestamp(epoch, tz=timezone.utc).isocalendar()
|
|
55
|
+
return f"{year}-W{week:02d}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class IngestReport:
|
|
60
|
+
files: int = 0
|
|
61
|
+
rows: int = 0
|
|
62
|
+
zero_duration: int = 0
|
|
63
|
+
still_running: int = 0
|
|
64
|
+
cached: int = 0
|
|
65
|
+
cache_rebuilt: bool = False
|
|
66
|
+
parse: ParseStats = field(default_factory=ParseStats)
|
|
67
|
+
|
|
68
|
+
def lines(self) -> list[str]:
|
|
69
|
+
parse = self.parse
|
|
70
|
+
lines = [
|
|
71
|
+
f"files seen: {self.files}",
|
|
72
|
+
f"read from cache: {self.cached}" + (" (cache rebuilt)" if self.cache_rebuilt else ""),
|
|
73
|
+
f"rows produced: {self.rows}",
|
|
74
|
+
f"empty runs: {parse.empty_runs}",
|
|
75
|
+
f"unreadable files: {parse.unreadable_files}",
|
|
76
|
+
f"unparsed lines: {parse.unparsed_lines}",
|
|
77
|
+
f"zero-duration: {self.zero_duration}",
|
|
78
|
+
f"still running: {self.still_running}",
|
|
79
|
+
]
|
|
80
|
+
if parse.skipped_files:
|
|
81
|
+
lines.append(f"skipped files: {parse.skipped_files}")
|
|
82
|
+
if parse.skipped_runs:
|
|
83
|
+
lines.append(f"skipped runs: {parse.skipped_runs}")
|
|
84
|
+
return lines
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SessionClock:
|
|
88
|
+
"""Maps real time onto a session's own clock, with idle gaps clipped."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, runs: Sequence[RawRun], idle_cap_s: int) -> None:
|
|
91
|
+
merged: list[list[float]] = []
|
|
92
|
+
for start, end in sorted(span for run in runs for span in run.spans):
|
|
93
|
+
if merged and start <= merged[-1][1]:
|
|
94
|
+
merged[-1][1] = max(merged[-1][1], end)
|
|
95
|
+
else:
|
|
96
|
+
merged.append([start, end])
|
|
97
|
+
self._starts = [start for start, _ in merged]
|
|
98
|
+
self._ends = [end for _, end in merged]
|
|
99
|
+
self._elapsed = [0.0]
|
|
100
|
+
for i in range(1, len(merged)):
|
|
101
|
+
busy = self._ends[i - 1] - self._starts[i - 1]
|
|
102
|
+
gap = self._starts[i] - self._ends[i - 1]
|
|
103
|
+
self._elapsed.append(self._elapsed[-1] + busy + min(gap, idle_cap_s))
|
|
104
|
+
|
|
105
|
+
def at(self, time: float) -> int:
|
|
106
|
+
index = max(bisect_right(self._starts, time) - 1, 0)
|
|
107
|
+
return round(self._elapsed[index] + max(time - self._starts[index], 0.0))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def peak_overlaps(runs: Sequence[RawRun]) -> dict[int, int]:
|
|
111
|
+
"""Peak number of runs alive at once during each run, including itself."""
|
|
112
|
+
events = sorted(
|
|
113
|
+
(time, order, index)
|
|
114
|
+
for index, run in enumerate(runs)
|
|
115
|
+
for time, order in ((run.start, 0), (run.end, 1))
|
|
116
|
+
)
|
|
117
|
+
peaks = dict.fromkeys(range(len(runs)), 1)
|
|
118
|
+
live: set[int] = set()
|
|
119
|
+
for _, order, index in events:
|
|
120
|
+
if order == 1:
|
|
121
|
+
live.discard(index)
|
|
122
|
+
continue
|
|
123
|
+
live.add(index)
|
|
124
|
+
for member in live:
|
|
125
|
+
peaks[member] = max(peaks[member], len(live))
|
|
126
|
+
return peaks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_labels(path: Path) -> dict[str, dict[str, str]]:
|
|
130
|
+
if not path.exists():
|
|
131
|
+
return {}
|
|
132
|
+
labels: dict[str, dict[str, str]] = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
+
return labels
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def apply_labels(row: Row, labels: Mapping[str, Mapping[str, str]]) -> None:
|
|
137
|
+
label = labels.get(run_ref(row))
|
|
138
|
+
if label:
|
|
139
|
+
row["task_kind"] = label.get("kind", row["task_kind"])
|
|
140
|
+
row["risk"] = label.get("risk", row["risk"])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_ref(row: Row) -> str:
|
|
144
|
+
"""A short handle for labeling one run."""
|
|
145
|
+
return f"{row['batch']}:{row['offset_s']}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_rows(
|
|
149
|
+
runs: Sequence[RawRun],
|
|
150
|
+
secret: bytes,
|
|
151
|
+
idle_cap_s: int = DEFAULT_IDLE_CAP_S,
|
|
152
|
+
kind_map: Mapping[str, str] | None = None,
|
|
153
|
+
labels: Mapping[str, Mapping[str, str]] | None = None,
|
|
154
|
+
now: float | None = None,
|
|
155
|
+
) -> tuple[list[Row], int, int]:
|
|
156
|
+
"""Return schema-valid rows, how many runs had zero duration and were dropped, and how many
|
|
157
|
+
were withheld because they may still be running.
|
|
158
|
+
|
|
159
|
+
A run that has not completed and was active within the idle cap may still be going. Storing
|
|
160
|
+
it would leave a partial row behind for good, because its duration is part of the key, so it
|
|
161
|
+
is withheld until a later ingest finds it settled. It still feeds the session clock and the
|
|
162
|
+
overlap count, so its siblings get the same offsets they will have once it finishes. A
|
|
163
|
+
completed sibling that starts in the same second is held back too, because tie-breaking
|
|
164
|
+
between the two can only be settled once both are final.
|
|
165
|
+
"""
|
|
166
|
+
now = time.time() if now is None else now
|
|
167
|
+
sessions: dict[tuple[str, str], list[RawRun]] = defaultdict(list)
|
|
168
|
+
for run in runs:
|
|
169
|
+
sessions[(run.project, run.session)].append(run)
|
|
170
|
+
contributor = pseudonym.contributor(secret)
|
|
171
|
+
rows: list[Row] = []
|
|
172
|
+
dropped = withheld = 0
|
|
173
|
+
for (project, session), members in sessions.items():
|
|
174
|
+
clock = SessionClock(members, idle_cap_s)
|
|
175
|
+
peaks = peak_overlaps(members)
|
|
176
|
+
batch = pseudonym.batch(secret, session)
|
|
177
|
+
project_hash = pseudonym.project(secret, project)
|
|
178
|
+
taken: set[tuple[int, int]] = set()
|
|
179
|
+
order = sorted(range(len(members)), key=lambda i: (members[i].start, members[i].end))
|
|
180
|
+
base = {i: clock.at(members[i].start) for i in order}
|
|
181
|
+
running = {
|
|
182
|
+
i
|
|
183
|
+
for i in order
|
|
184
|
+
if members[i].outcome != "completed" and now - members[i].end < idle_cap_s
|
|
185
|
+
}
|
|
186
|
+
# A sibling that starts in the same second as a running run is held back with it, since
|
|
187
|
+
# the tie nudge below would give it a different key once the running one finishes.
|
|
188
|
+
blocked = {base[i] for i in running}
|
|
189
|
+
for index in order:
|
|
190
|
+
run = members[index]
|
|
191
|
+
if run.active_s < 1:
|
|
192
|
+
dropped += 1
|
|
193
|
+
continue
|
|
194
|
+
if index in running or base[index] in blocked:
|
|
195
|
+
withheld += 1
|
|
196
|
+
continue
|
|
197
|
+
# Agents launched together can tie on (start, duration) to the second. A one-second
|
|
198
|
+
# nudge keeps both runs apart under the duplicate key, deterministically.
|
|
199
|
+
offset = base[index]
|
|
200
|
+
while (offset, run.active_s) in taken:
|
|
201
|
+
offset += 1
|
|
202
|
+
taken.add((offset, run.active_s))
|
|
203
|
+
kind = classify.classify_kind(run.agent_type, kind_map)
|
|
204
|
+
row: Row = {
|
|
205
|
+
"harness": run.harness,
|
|
206
|
+
"model": normalize_model(run.model),
|
|
207
|
+
"task_kind": kind,
|
|
208
|
+
"risk": classify.classify_risk(run.agent_type, kind),
|
|
209
|
+
"size": classify.size_bucket(run.edit_calls),
|
|
210
|
+
"duration_s": run.active_s,
|
|
211
|
+
"tool_calls": run.tool_calls,
|
|
212
|
+
"retries": run.errors,
|
|
213
|
+
"outcome": run.outcome,
|
|
214
|
+
"parallel_agents": peaks[index],
|
|
215
|
+
"contributor": contributor,
|
|
216
|
+
"week": iso_week(run.start),
|
|
217
|
+
"batch": batch,
|
|
218
|
+
"project": project_hash,
|
|
219
|
+
"offset_s": offset,
|
|
220
|
+
}
|
|
221
|
+
if run.tokens is not None:
|
|
222
|
+
row["tokens"] = run.tokens
|
|
223
|
+
if run.effort in _EFFORTS:
|
|
224
|
+
row["effort"] = run.effort
|
|
225
|
+
if labels:
|
|
226
|
+
apply_labels(row, labels)
|
|
227
|
+
problems = schema.check_row(row)
|
|
228
|
+
if problems:
|
|
229
|
+
raise ValueError(f"adapter produced an invalid row: {problems}")
|
|
230
|
+
rows.append(row)
|
|
231
|
+
return rows, dropped, withheld
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _stamp(path: str) -> list[int] | None:
|
|
235
|
+
try:
|
|
236
|
+
stamp = os.stat(path)
|
|
237
|
+
except OSError:
|
|
238
|
+
return None
|
|
239
|
+
return [stamp.st_size, stamp.st_mtime_ns]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse(
|
|
243
|
+
adapter: Adapter,
|
|
244
|
+
source: Source,
|
|
245
|
+
idle_cap_s: int,
|
|
246
|
+
report: IngestReport,
|
|
247
|
+
cache: RunCache | None,
|
|
248
|
+
) -> list[RawRun]:
|
|
249
|
+
if cache is None:
|
|
250
|
+
return adapter.parse(source, idle_cap_s, report.parse)
|
|
251
|
+
try:
|
|
252
|
+
stamp = os.stat(source.path)
|
|
253
|
+
except OSError:
|
|
254
|
+
report.parse.unreadable_files += 1
|
|
255
|
+
return []
|
|
256
|
+
stamps = [[stamp.st_size, stamp.st_mtime_ns], *map(_stamp, source.companions)]
|
|
257
|
+
hit, runs = cache.lookup(source.path, stamps)
|
|
258
|
+
if hit:
|
|
259
|
+
report.cached += 1
|
|
260
|
+
if not runs:
|
|
261
|
+
report.parse.empty_runs += 1
|
|
262
|
+
return runs
|
|
263
|
+
unreadable = report.parse.unreadable_files
|
|
264
|
+
runs = adapter.parse(source, idle_cap_s, report.parse)
|
|
265
|
+
if report.parse.unreadable_files == unreadable:
|
|
266
|
+
# Normalize before storing, so no raw model name (an ARN, a proxy) sits on disk.
|
|
267
|
+
runs = [replace(run, model=normalize_model(run.model)) for run in runs]
|
|
268
|
+
cache.store(source.path, stamps, runs)
|
|
269
|
+
return runs
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def ingest(
|
|
273
|
+
adapter: Adapter,
|
|
274
|
+
path: Path,
|
|
275
|
+
secret: bytes,
|
|
276
|
+
idle_cap_s: int = DEFAULT_IDLE_CAP_S,
|
|
277
|
+
kind_map: Mapping[str, str] | None = None,
|
|
278
|
+
labels: Mapping[str, Mapping[str, str]] | None = None,
|
|
279
|
+
now: float | None = None,
|
|
280
|
+
cache: RunCache | None = None,
|
|
281
|
+
) -> tuple[list[Row], IngestReport]:
|
|
282
|
+
"""Parse every run under `path` into rows. With a cache, unchanged files are not re-read."""
|
|
283
|
+
report = IngestReport(cache_rebuilt=cache is not None and cache.rebuilt)
|
|
284
|
+
runs: list[RawRun] = []
|
|
285
|
+
seen: set[str] = set()
|
|
286
|
+
for source in adapter.discover(path):
|
|
287
|
+
report.files += 1
|
|
288
|
+
seen.add(source.path)
|
|
289
|
+
runs.extend(_parse(adapter, source, idle_cap_s, report, cache))
|
|
290
|
+
if cache is not None:
|
|
291
|
+
sessions = {(run.project, run.session) for run in runs}
|
|
292
|
+
runs += cache.vanished(sessions, seen)
|
|
293
|
+
cache.save()
|
|
294
|
+
rows, report.zero_duration, report.still_running = build_rows(
|
|
295
|
+
runs, secret, idle_cap_s, kind_map, labels, now
|
|
296
|
+
)
|
|
297
|
+
report.rows = len(rows)
|
|
298
|
+
return rows, report
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def merge_into(path: Path, rows: Sequence[Row]) -> int:
|
|
302
|
+
"""Add rows to a store. Old rows survive, since harnesses delete their transcripts."""
|
|
303
|
+
existing = schema.read_rows(path) if path.exists() else []
|
|
304
|
+
merged = {schema.dedupe_key(row): row for row in [*existing, *rows]}
|
|
305
|
+
return schema.write_rows(path, (merged[key] for key in sorted(merged)))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class StoreBusy(Exception):
|
|
309
|
+
"""Another ingest holds the store."""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@contextmanager
|
|
313
|
+
def store_lock(store: Path, wait_s: float = 5.0, stale_s: float = 900.0) -> Iterator[None]:
|
|
314
|
+
"""Hold `<store>.lock` while reading, merging and writing, so a hook and a manual ingest
|
|
315
|
+
never interleave. A lock older than `stale_s` is taken to be left by a crash. That is longer
|
|
316
|
+
than a cold ingest takes, so a live holder is never mistaken for a dead one."""
|
|
317
|
+
lock = store.with_name(store.name + ".lock")
|
|
318
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
deadline = time.monotonic() + wait_s
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
323
|
+
break
|
|
324
|
+
except (FileExistsError, PermissionError):
|
|
325
|
+
# Windows reports PermissionError while another holder's lock is being deleted.
|
|
326
|
+
with suppress(OSError):
|
|
327
|
+
if time.time() - lock.stat().st_mtime > stale_s:
|
|
328
|
+
lock.unlink()
|
|
329
|
+
continue
|
|
330
|
+
if time.monotonic() > deadline:
|
|
331
|
+
raise StoreBusy(str(lock)) from None
|
|
332
|
+
time.sleep(0.05)
|
|
333
|
+
try:
|
|
334
|
+
yield
|
|
335
|
+
finally:
|
|
336
|
+
with suppress(FileNotFoundError):
|
|
337
|
+
lock.unlink()
|
agentpace/paths.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Where agentpace finds its files.
|
|
2
|
+
|
|
3
|
+
Installed wheels carry the schema and seed rows under `_bundled`. A source checkout reads the
|
|
4
|
+
same files from the repo root, so there is never a second copy to drift.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_PACKAGE = Path(__file__).resolve().parent
|
|
13
|
+
_REPO_ROOT = _PACKAGE.parent.parent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def home() -> Path:
|
|
17
|
+
"""Directory for the local secret, labels and ingested rows."""
|
|
18
|
+
override = os.environ.get("AGENTPACE_HOME")
|
|
19
|
+
return Path(override) if override else Path.home() / ".agentpace"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def schema_file() -> Path:
|
|
23
|
+
bundled = _PACKAGE / "_bundled" / "run.schema.json"
|
|
24
|
+
return bundled if bundled.exists() else _REPO_ROOT / "schema" / "run.schema.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def turn_schema_file() -> Path:
|
|
28
|
+
bundled = _PACKAGE / "_bundled" / "turn.schema.json"
|
|
29
|
+
return bundled if bundled.exists() else _REPO_ROOT / "schema" / "turn.schema.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def seed_dir() -> Path:
|
|
33
|
+
bundled = _PACKAGE / "_bundled" / "runs"
|
|
34
|
+
return bundled if bundled.exists() else _REPO_ROOT / "data" / "runs"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def example_plan() -> Path:
|
|
38
|
+
"""The plan the README's Try it runs, shipped so it needs no clone."""
|
|
39
|
+
bundled = _PACKAGE / "_bundled" / "example-plan.json"
|
|
40
|
+
return bundled if bundled.exists() else _REPO_ROOT / "examples" / "plan.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def local_runs() -> Path:
|
|
44
|
+
return home() / "runs.jsonl"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def turns_file() -> Path:
|
|
48
|
+
"""Waits on the human, from main sessions. Local only, never shared."""
|
|
49
|
+
return home() / "turns.jsonl"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def forecasts_file() -> Path:
|
|
53
|
+
"""Recorded forecasts and their scores. Local only, never shared."""
|
|
54
|
+
return home() / "forecasts.jsonl"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def labels_file() -> Path:
|
|
58
|
+
return home() / "labels.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cache_file(adapter: str) -> Path:
|
|
62
|
+
return home() / f"cache-{adapter}.json"
|