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/share.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Prepare rows for contribution. Local only: this module writes a file and nothing else.
|
|
2
|
+
|
|
3
|
+
There is deliberately no network code here or anywhere else in the package. Sending the file is a
|
|
4
|
+
git push the contributor runs themselves, after reading exactly what it holds.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agentpace import schema
|
|
14
|
+
from agentpace.adapters import EXPERIMENTAL
|
|
15
|
+
from agentpace.schema import Row
|
|
16
|
+
|
|
17
|
+
NOTICE = """\
|
|
18
|
+
What these rows reveal, even without any content:
|
|
19
|
+
- Rows from you link to each other through `contributor`.
|
|
20
|
+
- Rows from one session link through `batch`, and `project` groups a local project.
|
|
21
|
+
Use --drop-project to omit it.
|
|
22
|
+
- `week` and the mix of models show roughly when you worked and on what plan tier.
|
|
23
|
+
- A pull request ties this pseudonym to your GitHub identity in public history.
|
|
24
|
+
- Submitting a data pull request dedicates these rows to the public domain under CC0-1.0.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def select(
|
|
29
|
+
rows: Iterable[Row],
|
|
30
|
+
since: str | None = None,
|
|
31
|
+
drop_project: bool = False,
|
|
32
|
+
already_published: Iterable[Row] = (),
|
|
33
|
+
drop_sessions: Iterable[str] = (),
|
|
34
|
+
) -> tuple[list[Row], int, dict[str, int]]:
|
|
35
|
+
"""Choose what to share. Returns the rows, how many were already published, and the rows
|
|
36
|
+
left out per dropped session.
|
|
37
|
+
|
|
38
|
+
A dropped session is withheld whole, before anything else is decided, so the file name and
|
|
39
|
+
the published count see only the rows actually shared. Every dropped session must match a
|
|
40
|
+
local row: a mistyped one would otherwise share the session it meant to withhold.
|
|
41
|
+
"""
|
|
42
|
+
known = {schema.dedupe_key(row) for row in already_published}
|
|
43
|
+
dropped = dict.fromkeys(drop_sessions, 0)
|
|
44
|
+
chosen: list[Row] = []
|
|
45
|
+
skipped = 0
|
|
46
|
+
for row in rows:
|
|
47
|
+
if row["batch"] in dropped:
|
|
48
|
+
dropped[row["batch"]] += 1
|
|
49
|
+
continue
|
|
50
|
+
if since and row["week"] < since:
|
|
51
|
+
continue
|
|
52
|
+
if schema.dedupe_key(row) in known:
|
|
53
|
+
skipped += 1
|
|
54
|
+
continue
|
|
55
|
+
kept = {key: value for key, value in row.items() if key != "project" or not drop_project}
|
|
56
|
+
problems = schema.check_row(kept)
|
|
57
|
+
if problems:
|
|
58
|
+
raise ValueError(f"refusing to share an invalid row: {problems}")
|
|
59
|
+
chosen.append(kept)
|
|
60
|
+
unmatched = sorted(batch for batch, count in dropped.items() if not count)
|
|
61
|
+
if unmatched:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"--drop-session names {len(unmatched)} sessions with no local run "
|
|
64
|
+
f"({', '.join(unmatched)}); nothing was written. See `agentpace runs`"
|
|
65
|
+
)
|
|
66
|
+
return sorted(chosen, key=schema.dedupe_key), skipped, dropped
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def file_name(rows: Sequence[Row]) -> str:
|
|
70
|
+
weeks = sorted(row["week"] for row in rows)
|
|
71
|
+
return f"{rows[0]['contributor']}-{weeks[0]}-{weeks[-1]}.jsonl"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def held_rows(path: Path, rows: Sequence[Row]) -> int | None:
|
|
75
|
+
"""How many rows `path` already holds, or None when it does not exist yet.
|
|
76
|
+
|
|
77
|
+
Refuses a file that holds anyone else's rows: a contribution file belongs to one contributor.
|
|
78
|
+
"""
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return None
|
|
81
|
+
contributor = rows[0]["contributor"]
|
|
82
|
+
old = schema.read_rows(path)
|
|
83
|
+
if any(row.get("contributor") != contributor for row in old):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"refusing to add rows to {path.name}: it holds another contributor's rows"
|
|
86
|
+
)
|
|
87
|
+
return len(old)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_contribution(path: Path, rows: Sequence[Row]) -> tuple[int, int]:
|
|
91
|
+
"""Create `path`, or extend it. Returns (rows added, rows it already held).
|
|
92
|
+
|
|
93
|
+
An existing file keeps its bytes exactly; the new rows go after them. `select` has already
|
|
94
|
+
left out every published run, so an appended row is never a duplicate.
|
|
95
|
+
"""
|
|
96
|
+
if not rows:
|
|
97
|
+
raise ValueError("nothing to write")
|
|
98
|
+
existing = held_rows(path, rows) or 0
|
|
99
|
+
old = path.read_bytes() if path.exists() else b""
|
|
100
|
+
if old and not old.endswith(b"\n"):
|
|
101
|
+
old += b"\n"
|
|
102
|
+
new = "".join(schema.canonical(row) + "\n" for row in rows).encode("utf-8")
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
105
|
+
temporary.write_bytes(old + new)
|
|
106
|
+
os.replace(temporary, path)
|
|
107
|
+
return len(rows), existing
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def experimental_note(rows: Iterable[Row]) -> str | None:
|
|
111
|
+
"""A warning when rows from unverified adapters are in the share. They stay in it."""
|
|
112
|
+
harnesses = [row["harness"] for row in rows if row["harness"] in EXPERIMENTAL]
|
|
113
|
+
if not harnesses:
|
|
114
|
+
return None
|
|
115
|
+
names = ", ".join(sorted(set(harnesses)))
|
|
116
|
+
return (
|
|
117
|
+
f"{len(harnesses)} rows come from experimental adapters ({names}). validate rejects them"
|
|
118
|
+
" until the adapter is verified, so a pull request with them stays red until a maintainer"
|
|
119
|
+
" reconciles them by hand; see docs/verify-adapter.md."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def git_commands(path: Path) -> list[str]:
|
|
124
|
+
return [
|
|
125
|
+
f'agentpace validate "{path.parent}"',
|
|
126
|
+
f'git add "{path}"',
|
|
127
|
+
'git commit -m "data: add runs"',
|
|
128
|
+
"git push origin HEAD",
|
|
129
|
+
"Then open a pull request.",
|
|
130
|
+
]
|
agentpace/site.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Build the data block inside `docs/index.html`.
|
|
2
|
+
|
|
3
|
+
The page is one static file with no build step. Its data sits inline, so it works from `file://`
|
|
4
|
+
and makes no requests of any kind. Fetching a JSON file would add a network call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from html import escape
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from agentpace import estimate as est
|
|
18
|
+
from agentpace import render
|
|
19
|
+
from agentpace.schema import Row
|
|
20
|
+
|
|
21
|
+
START = '<script id="leaderboard-data" type="application/json">'
|
|
22
|
+
END = "</script>"
|
|
23
|
+
BINS_PER_DECADE = 10
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _summary(values: Sequence[int]) -> dict[str, int]:
|
|
27
|
+
ordered = sorted(values)
|
|
28
|
+
return {
|
|
29
|
+
"n": len(ordered),
|
|
30
|
+
"p50": round(est.quantile(ordered, 0.5)),
|
|
31
|
+
"p75": round(est.quantile(ordered, 0.75)),
|
|
32
|
+
"p90": round(est.quantile(ordered, 0.9)),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _histogram(values: Sequence[int]) -> list[dict[str, int]]:
|
|
37
|
+
"""Counts on a log axis. Aggregates only: individual runs are not embedded in the page."""
|
|
38
|
+
counts: dict[int, int] = defaultdict(int)
|
|
39
|
+
for value in values:
|
|
40
|
+
counts[math.floor(math.log10(value) * BINS_PER_DECADE)] += 1
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
"low": round(10 ** (index / BINS_PER_DECADE)),
|
|
44
|
+
"high": round(10 ** ((index + 1) / BINS_PER_DECADE)),
|
|
45
|
+
"n": counts[index],
|
|
46
|
+
}
|
|
47
|
+
for index in range(max(counts) + 1)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def payload(rows: Sequence[Row], backtest_report: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
completed = [row for row in rows if row["outcome"] == "completed"]
|
|
53
|
+
cells: dict[tuple[str, str, str], list[int]] = defaultdict(list)
|
|
54
|
+
for row in completed:
|
|
55
|
+
cells[(row["model"], row["task_kind"], row["risk"])].append(row["duration_s"])
|
|
56
|
+
durations = [row["duration_s"] for row in completed]
|
|
57
|
+
weeks = sorted({row["week"] for row in rows})
|
|
58
|
+
time_split = backtest_report["splits"]["time"]["tracks"]["pre-run"]
|
|
59
|
+
winner = backtest_report["selection"]["winner"]
|
|
60
|
+
plans = backtest_report["plans"]
|
|
61
|
+
return {
|
|
62
|
+
"runs": len(rows),
|
|
63
|
+
"completed": len(completed),
|
|
64
|
+
"sessions": len({row["batch"] for row in rows}),
|
|
65
|
+
"contributors": len({row["contributor"] for row in rows}),
|
|
66
|
+
"first_week": weeks[0],
|
|
67
|
+
"last_week": weeks[-1],
|
|
68
|
+
"overall": _summary(durations),
|
|
69
|
+
"histogram": _histogram(durations),
|
|
70
|
+
"cells": [
|
|
71
|
+
{"model": key[0], "kind": key[1], "risk": key[2], **_summary(values)}
|
|
72
|
+
for key, values in sorted(cells.items())
|
|
73
|
+
],
|
|
74
|
+
"backtest": {
|
|
75
|
+
"sessions": plans["sessions"],
|
|
76
|
+
"plan_p80_coverage": round(plans["results"]["agentpace"]["coverage80"]),
|
|
77
|
+
"plan_p90_coverage": round(plans["results"]["agentpace"]["coverage90"]),
|
|
78
|
+
"task_p80_coverage": round(time_split[winner]["session"]["coverage80"]),
|
|
79
|
+
"task_mdape": round(time_split[winner]["session"]["mdape"]),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def texts(data: dict[str, Any]) -> dict[str, str]:
|
|
85
|
+
"""Sentences the page shows before any script runs, so link previews, readers without
|
|
86
|
+
JavaScript and crawlers all see the same numbers as everyone else."""
|
|
87
|
+
overall, scores = data["overall"], data["backtest"]
|
|
88
|
+
people = "contributor" if data["contributors"] == 1 else "contributors"
|
|
89
|
+
return {
|
|
90
|
+
"lede": (
|
|
91
|
+
f"Measured from {data['completed']:,} completed agent runs, not from anyone's "
|
|
92
|
+
f"opinion. Half finished within {render.duration(overall['p50'])}. One in ten took "
|
|
93
|
+
f"more than {render.duration(overall['p90'])}."
|
|
94
|
+
),
|
|
95
|
+
"trace-note": (
|
|
96
|
+
f"Every completed run from {data['first_week']} to {data['last_week']}. "
|
|
97
|
+
"Time runs left to right on a log scale."
|
|
98
|
+
),
|
|
99
|
+
"reading-scope": (
|
|
100
|
+
f"{data['runs']:,} runs in {data['sessions']} sessions from {data['contributors']} "
|
|
101
|
+
f"{people}, with heavy automated fan-out. It will not match your setup until more "
|
|
102
|
+
"people contribute."
|
|
103
|
+
),
|
|
104
|
+
"reading-backtest": (
|
|
105
|
+
f"On {scores['sessions']} held-out sessions, the P80 for the whole session covered "
|
|
106
|
+
f"{scores['plan_p80_coverage']}% and the P90 covered {scores['plan_p90_coverage']}%. "
|
|
107
|
+
f"For a single task the median miss is {scores['task_mdape']}%, so read a range, "
|
|
108
|
+
"never one number."
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _fill(html: str, element_id: str, text: str) -> str:
|
|
114
|
+
pattern = re.compile(rf'(id="{element_id}"[^>]*>)(.*?)(</(?:p|figcaption)>)', re.DOTALL)
|
|
115
|
+
filled, count = pattern.subn(lambda m: m[1] + escape(text, quote=False) + m[3], html, count=1)
|
|
116
|
+
if count != 1:
|
|
117
|
+
raise ValueError(f"the page has no element with id {element_id}")
|
|
118
|
+
return filled
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def embed(html: str, data: dict[str, Any]) -> str:
|
|
122
|
+
start = html.index(START) + len(START)
|
|
123
|
+
end = html.index(END, start)
|
|
124
|
+
block = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
|
|
125
|
+
return f"{html[:start]}{block}{html[end:]}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build(html: str, data: dict[str, Any]) -> str:
|
|
129
|
+
"""The data block plus the sentences drawn from it."""
|
|
130
|
+
page = embed(html, data)
|
|
131
|
+
for element_id, text in texts(data).items():
|
|
132
|
+
page = _fill(page, element_id, text)
|
|
133
|
+
return page
|
agentpace/turns.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Waiting on the human, measured from main-session timestamps. Local only.
|
|
2
|
+
|
|
3
|
+
A main Claude Code session alternates between agent turns and human prompts. The time from the
|
|
4
|
+
agent's last record to the next prompt a person typed is a wait on the human: review, testing,
|
|
5
|
+
decisions and everything else between turns, breaks included. It is elapsed time, not effort.
|
|
6
|
+
|
|
7
|
+
Main sessions are never a source of run rows. This module reads them for this one quantity, and
|
|
8
|
+
copies four things out of each parsed line: the record type, its timestamp, its `turnOrigin`
|
|
9
|
+
(only whether it says a human typed the prompt) and whether it carries a tool result. Nothing it
|
|
10
|
+
writes reaches `share`, the dataset or a run row.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import statistics
|
|
18
|
+
from collections.abc import Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from agentpace import estimate as est
|
|
24
|
+
from agentpace import ingest, paths, pseudonym, schema
|
|
25
|
+
from agentpace.adapters.claude_code import _extended, _parse_time
|
|
26
|
+
|
|
27
|
+
Turn = dict[str, Any]
|
|
28
|
+
|
|
29
|
+
HOUR_S = 3600
|
|
30
|
+
_ASSISTANT, _HUMAN, _OTHER = "assistant", "human", "other"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class _Mark:
|
|
35
|
+
time: float
|
|
36
|
+
kind: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Gap:
|
|
41
|
+
time: float
|
|
42
|
+
gap_s: int
|
|
43
|
+
agent_turn_s: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TurnsReport:
|
|
48
|
+
files: int = 0
|
|
49
|
+
sessions: int = 0
|
|
50
|
+
gaps: int = 0
|
|
51
|
+
no_origin: int = 0
|
|
52
|
+
too_short: int = 0
|
|
53
|
+
unreadable: int = 0
|
|
54
|
+
kept_from_earlier: int = 0
|
|
55
|
+
|
|
56
|
+
def lines(self) -> list[str]:
|
|
57
|
+
lines = [
|
|
58
|
+
f"read {self.files} main-session transcripts: {self.gaps} gaps in "
|
|
59
|
+
f"{self.sessions} sessions"
|
|
60
|
+
]
|
|
61
|
+
if self.no_origin:
|
|
62
|
+
lines.append(
|
|
63
|
+
f"skipped {self.no_origin} sessions with no turnOrigin, so human prompts cannot "
|
|
64
|
+
"be told apart from other records"
|
|
65
|
+
)
|
|
66
|
+
if self.too_short:
|
|
67
|
+
lines.append(f"skipped {self.too_short} sessions with fewer than 2 human prompts")
|
|
68
|
+
if self.unreadable:
|
|
69
|
+
lines.append(f"could not read {self.unreadable} files")
|
|
70
|
+
if self.kept_from_earlier:
|
|
71
|
+
lines.append(
|
|
72
|
+
f"kept {self.kept_from_earlier} gaps from sessions whose transcripts are gone"
|
|
73
|
+
)
|
|
74
|
+
return lines
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _schema() -> dict[str, Any]:
|
|
78
|
+
return schema.load_schema_file(paths.turn_schema_file())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_turn(turn: Any) -> list[str]:
|
|
82
|
+
return schema.check_against(_schema(), turn)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _project(record: Any) -> tuple[str, float, bool | None] | None:
|
|
86
|
+
"""Copy the allowed values out of a parsed line: the only code that touches one.
|
|
87
|
+
|
|
88
|
+
Returns the mark kind, its time, and whether the line carried a `turnOrigin` key at all
|
|
89
|
+
(None for assistant records, where it does not apply)."""
|
|
90
|
+
if not isinstance(record, dict):
|
|
91
|
+
return None
|
|
92
|
+
role = record.get("type")
|
|
93
|
+
time = _parse_time(record.get("timestamp"))
|
|
94
|
+
if time is None:
|
|
95
|
+
return None
|
|
96
|
+
if role == "assistant":
|
|
97
|
+
return (_ASSISTANT, time, None)
|
|
98
|
+
if role != "user":
|
|
99
|
+
return None
|
|
100
|
+
message = record.get("message")
|
|
101
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
102
|
+
if isinstance(content, list) and any(
|
|
103
|
+
isinstance(block, dict) and block.get("type") == "tool_result" for block in content
|
|
104
|
+
):
|
|
105
|
+
return (_OTHER, time, None)
|
|
106
|
+
origin = record.get("turnOrigin")
|
|
107
|
+
return (_HUMAN if origin == "human" else _OTHER, time, "turnOrigin" in record)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def gaps(marks: Sequence[_Mark]) -> list[Gap]:
|
|
111
|
+
"""Each human prompt after an agent record opens a gap, paired with the agent turn before
|
|
112
|
+
it."""
|
|
113
|
+
found: list[Gap] = []
|
|
114
|
+
last_agent: float | None = None
|
|
115
|
+
previous_prompt: float | None = None
|
|
116
|
+
for mark in sorted(marks, key=lambda m: m.time):
|
|
117
|
+
if mark.kind == _ASSISTANT:
|
|
118
|
+
last_agent = mark.time
|
|
119
|
+
elif mark.kind == _HUMAN:
|
|
120
|
+
if last_agent is not None:
|
|
121
|
+
turn = 0.0
|
|
122
|
+
if previous_prompt is not None and last_agent >= previous_prompt:
|
|
123
|
+
turn = last_agent - previous_prompt
|
|
124
|
+
found.append(Gap(mark.time, round(mark.time - last_agent), round(turn)))
|
|
125
|
+
previous_prompt = mark.time
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class _Session:
|
|
131
|
+
marks: list[_Mark] = field(default_factory=list)
|
|
132
|
+
has_origin: bool = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read(path: str) -> _Session:
|
|
136
|
+
session = _Session()
|
|
137
|
+
with open(path, encoding="utf-8") as handle:
|
|
138
|
+
for line in handle:
|
|
139
|
+
try:
|
|
140
|
+
record = json.loads(line)
|
|
141
|
+
except ValueError:
|
|
142
|
+
continue
|
|
143
|
+
projected = _project(record)
|
|
144
|
+
if projected is None:
|
|
145
|
+
continue
|
|
146
|
+
kind, time, carries_origin = projected
|
|
147
|
+
session.marks.append(_Mark(time, kind))
|
|
148
|
+
session.has_origin = session.has_origin or bool(carries_origin)
|
|
149
|
+
return session
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def discover(root: Path) -> Iterator[tuple[str, str]]:
|
|
153
|
+
"""Main-session transcripts sit directly in each project folder as `<session>.jsonl`."""
|
|
154
|
+
base = _extended(root)
|
|
155
|
+
try:
|
|
156
|
+
projects = sorted(os.scandir(base), key=lambda entry: entry.name)
|
|
157
|
+
except OSError:
|
|
158
|
+
return
|
|
159
|
+
for project in projects:
|
|
160
|
+
if not project.is_dir():
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
files = sorted(os.scandir(project.path), key=lambda entry: entry.name)
|
|
164
|
+
except OSError:
|
|
165
|
+
continue
|
|
166
|
+
for entry in files:
|
|
167
|
+
if entry.is_file() and entry.name.endswith(".jsonl"):
|
|
168
|
+
yield entry.path, entry.name[: -len(".jsonl")]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def collect(root: Path, secret: bytes) -> tuple[list[Turn], set[str], TurnsReport]:
|
|
172
|
+
report = TurnsReport()
|
|
173
|
+
turns: list[Turn] = []
|
|
174
|
+
seen: set[str] = set()
|
|
175
|
+
for path, session_id in discover(root):
|
|
176
|
+
report.files += 1
|
|
177
|
+
try:
|
|
178
|
+
session = _read(path)
|
|
179
|
+
except (OSError, UnicodeDecodeError):
|
|
180
|
+
report.unreadable += 1
|
|
181
|
+
continue
|
|
182
|
+
if not session.has_origin:
|
|
183
|
+
report.no_origin += 1
|
|
184
|
+
continue
|
|
185
|
+
if sum(mark.kind == _HUMAN for mark in session.marks) < 2:
|
|
186
|
+
report.too_short += 1
|
|
187
|
+
continue
|
|
188
|
+
pseudonym_id = pseudonym.turn_session(secret, session_id)
|
|
189
|
+
seen.add(pseudonym_id)
|
|
190
|
+
found = gaps(session.marks)
|
|
191
|
+
if found:
|
|
192
|
+
report.sessions += 1
|
|
193
|
+
for gap in found:
|
|
194
|
+
turns.append(
|
|
195
|
+
{
|
|
196
|
+
"session": pseudonym_id,
|
|
197
|
+
"week": ingest.iso_week(gap.time),
|
|
198
|
+
"gap_s": gap.gap_s,
|
|
199
|
+
"agent_turn_s": gap.agent_turn_s,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
report.gaps = len(turns)
|
|
203
|
+
return turns, seen, report
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def read_turns(path: Path) -> list[Turn]:
|
|
207
|
+
if not path.exists():
|
|
208
|
+
return []
|
|
209
|
+
turns = []
|
|
210
|
+
with path.open(encoding="utf-8") as handle:
|
|
211
|
+
for number, line in enumerate(handle, 1):
|
|
212
|
+
if not line.strip():
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
turn = json.loads(line)
|
|
216
|
+
except ValueError:
|
|
217
|
+
turn = None
|
|
218
|
+
if turn is None or check_turn(turn):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"line {number} of {path.name} is not a valid turn; run agentpace turns"
|
|
221
|
+
)
|
|
222
|
+
turns.append(turn)
|
|
223
|
+
return turns
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def write_turns(path: Path, turns: Sequence[Turn]) -> None:
|
|
227
|
+
"""Atomic, like the run store, so an interrupted write cannot leave half a file."""
|
|
228
|
+
order = ("session", "week", "gap_s", "agent_turn_s")
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
231
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
|
232
|
+
for turn in turns:
|
|
233
|
+
handle.write(json.dumps({key: turn[key] for key in order}, separators=(",", ":")))
|
|
234
|
+
handle.write("\n")
|
|
235
|
+
os.replace(temporary, path)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def update(root: Path, store: Path, secret: bytes) -> TurnsReport:
|
|
239
|
+
"""Replace the gaps of every session read in this pass and keep the rest, because Claude
|
|
240
|
+
Code deletes old transcripts."""
|
|
241
|
+
fresh, seen, report = collect(root, secret)
|
|
242
|
+
kept = [turn for turn in read_turns(store) if turn["session"] not in seen]
|
|
243
|
+
report.kept_from_earlier = len(kept)
|
|
244
|
+
for turn in fresh:
|
|
245
|
+
problems = check_turn(turn)
|
|
246
|
+
if problems:
|
|
247
|
+
raise ValueError(f"a turn failed its schema: {problems}")
|
|
248
|
+
write_turns(store, kept + fresh)
|
|
249
|
+
return report
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class Summary:
|
|
254
|
+
gaps: int
|
|
255
|
+
sessions: int
|
|
256
|
+
p50: float
|
|
257
|
+
p80: float
|
|
258
|
+
p90: float
|
|
259
|
+
over_hour: float
|
|
260
|
+
waiting_share: float
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def summarize(turns: Sequence[Turn]) -> Summary | None:
|
|
264
|
+
"""Uncapped quantiles of the wait per human turn, with no constant applied."""
|
|
265
|
+
if not turns:
|
|
266
|
+
return None
|
|
267
|
+
values = sorted(float(turn["gap_s"]) for turn in turns)
|
|
268
|
+
totals: dict[str, list[float]] = {}
|
|
269
|
+
for turn in turns:
|
|
270
|
+
waiting, working = totals.setdefault(turn["session"], [0.0, 0.0])
|
|
271
|
+
totals[turn["session"]] = [waiting + turn["gap_s"], working + turn["agent_turn_s"]]
|
|
272
|
+
shares = [w / (w + a) for w, a in totals.values() if w + a > 0]
|
|
273
|
+
p50, p80, p90 = (est.quantile(values, q) for q in est.QUANTILES)
|
|
274
|
+
return Summary(
|
|
275
|
+
gaps=len(values),
|
|
276
|
+
sessions=len(totals),
|
|
277
|
+
p50=p50,
|
|
278
|
+
p80=p80,
|
|
279
|
+
p90=p90,
|
|
280
|
+
over_hour=sum(value > HOUR_S for value in values) / len(values),
|
|
281
|
+
waiting_share=statistics.median(shares) if shares else 0.0,
|
|
282
|
+
)
|
agentpace/validate.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Checks every data file must pass, locally and in CI.
|
|
2
|
+
|
|
3
|
+
Contributed files are parsed, never executed. Findings name a file, a line and a rule, and never
|
|
4
|
+
quote the offending value, because a CI log is public and the value may be the very thing that
|
|
5
|
+
should not be published.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agentpace import ingest, schema
|
|
17
|
+
from agentpace.adapters import EXPERIMENTAL
|
|
18
|
+
|
|
19
|
+
MAX_DURATION_S = 86_400
|
|
20
|
+
MAX_CALLS_PER_SECOND = 5
|
|
21
|
+
MAX_FILE_BYTES = 20_000_000
|
|
22
|
+
|
|
23
|
+
_PII = {
|
|
24
|
+
"email address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
|
|
25
|
+
"url": re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE),
|
|
26
|
+
"windows path": re.compile(r"[A-Za-z]:[\\/]"),
|
|
27
|
+
"unix path": re.compile(r"/(?:home|Users|root|var|tmp|etc|opt|mnt|workspace)/"),
|
|
28
|
+
"ip address": re.compile(r"(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])"),
|
|
29
|
+
"api key": re.compile(r"\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{8,})"),
|
|
30
|
+
"jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Finding:
|
|
36
|
+
file: str
|
|
37
|
+
line: int
|
|
38
|
+
rule: str
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return f"{self.file}:{self.line}: {self.rule}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scan_for_pii(text: str) -> list[str]:
|
|
45
|
+
"""Names of the PII shapes present. Runs over raw text, before any parsing."""
|
|
46
|
+
return [name for name, pattern in _PII.items() if pattern.search(text)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_values(row: schema.Row) -> list[str]:
|
|
50
|
+
"""Rules a schema cannot express."""
|
|
51
|
+
problems: list[str] = []
|
|
52
|
+
if row["harness"] in EXPERIMENTAL:
|
|
53
|
+
problems.append(
|
|
54
|
+
"harness adapter is experimental: rows are not published until it is verified"
|
|
55
|
+
)
|
|
56
|
+
if ingest.normalize_model(row["model"]) != row["model"]:
|
|
57
|
+
problems.append("model is not a known public family or 'other'")
|
|
58
|
+
if row["duration_s"] > MAX_DURATION_S:
|
|
59
|
+
problems.append("outlier: duration over 24 hours")
|
|
60
|
+
if row["tool_calls"] > row["duration_s"] * MAX_CALLS_PER_SECOND:
|
|
61
|
+
problems.append("outlier: implausible tool calls per second")
|
|
62
|
+
if row["outcome"] == "completed" and row["tool_calls"] == 0 and not row.get("tokens"):
|
|
63
|
+
problems.append("outlier: completed with no activity")
|
|
64
|
+
return problems
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_file(path: Path, seen: dict[tuple[str, str, int, int], str]) -> list[Finding]:
|
|
68
|
+
name = path.name
|
|
69
|
+
if path.suffix != ".jsonl":
|
|
70
|
+
return [Finding(name, 0, "data files must end in .jsonl")]
|
|
71
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
72
|
+
return [Finding(name, 0, "file is too large")]
|
|
73
|
+
try:
|
|
74
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
75
|
+
except UnicodeDecodeError:
|
|
76
|
+
return [Finding(name, 0, "file is not UTF-8")]
|
|
77
|
+
findings: list[Finding] = []
|
|
78
|
+
contributors: set[str] = set()
|
|
79
|
+
for number, line in enumerate(lines, start=1):
|
|
80
|
+
if not line.strip():
|
|
81
|
+
continue
|
|
82
|
+
for shape in scan_for_pii(line):
|
|
83
|
+
findings.append(Finding(name, number, f"looks like PII: {shape}"))
|
|
84
|
+
try:
|
|
85
|
+
row = json.loads(line)
|
|
86
|
+
except ValueError:
|
|
87
|
+
findings.append(Finding(name, number, "not valid JSON"))
|
|
88
|
+
continue
|
|
89
|
+
problems = schema.check_row(row)
|
|
90
|
+
if problems:
|
|
91
|
+
findings += [Finding(name, number, problem) for problem in problems]
|
|
92
|
+
continue
|
|
93
|
+
findings += [Finding(name, number, problem) for problem in check_values(row)]
|
|
94
|
+
key = schema.dedupe_key(row)
|
|
95
|
+
if key in seen:
|
|
96
|
+
findings.append(Finding(name, number, f"duplicate of a run in {seen[key]}"))
|
|
97
|
+
seen[key] = name
|
|
98
|
+
contributors.add(row["contributor"])
|
|
99
|
+
if len(contributors) > 1:
|
|
100
|
+
findings.append(Finding(name, 0, "a file must hold one contributor"))
|
|
101
|
+
elif contributors and not name.startswith(next(iter(contributors))):
|
|
102
|
+
findings.append(Finding(name, 0, "file name must start with its contributor"))
|
|
103
|
+
return findings
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate(sources: Sequence[Path]) -> list[Finding]:
|
|
107
|
+
files: list[Path] = []
|
|
108
|
+
for source in sources:
|
|
109
|
+
files += sorted(p for p in source.iterdir() if p.is_file()) if source.is_dir() else [source]
|
|
110
|
+
seen: dict[tuple[str, str, int, int], str] = {}
|
|
111
|
+
findings: list[Finding] = []
|
|
112
|
+
for path in files:
|
|
113
|
+
if path.name != ".gitkeep":
|
|
114
|
+
findings += _check_file(path, seen)
|
|
115
|
+
return findings
|