agentic-bench 0.2.0__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.
- abench/__init__.py +107 -0
- abench/__main__.py +7 -0
- abench/_version.py +4 -0
- abench/cli.py +389 -0
- abench/core/__init__.py +72 -0
- abench/core/dataset.py +421 -0
- abench/core/metrics.py +505 -0
- abench/core/workload.py +614 -0
- abench/data/recording_rates.json +23 -0
- abench/replay/__init__.py +14 -0
- abench/replay/engine.py +553 -0
- abench/replay/transport.py +157 -0
- abench/runner.py +637 -0
- abench/serve/__init__.py +31 -0
- abench/serve/endpoints.py +388 -0
- abench/serve/report.py +365 -0
- abench/serve/telemetry.py +166 -0
- agentic_bench-0.2.0.dist-info/METADATA +169 -0
- agentic_bench-0.2.0.dist-info/RECORD +24 -0
- agentic_bench-0.2.0.dist-info/WHEEL +5 -0
- agentic_bench-0.2.0.dist-info/entry_points.txt +2 -0
- agentic_bench-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentic_bench-0.2.0.dist-info/licenses/NOTICE +44 -0
- agentic_bench-0.2.0.dist-info/top_level.txt +1 -0
abench/core/dataset.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
|
|
3
|
+
|
|
4
|
+
"""Dataset acquisition and loading for the ABench ModelScope mirror.
|
|
5
|
+
|
|
6
|
+
The ABench datasets live on ModelScope
|
|
7
|
+
(https://www.modelscope.cn/datasets/Apsara-Stack/ABench). Each tier is
|
|
8
|
+
one or more Zstandard-compressed JSONL files; every line is a complete
|
|
9
|
+
normalized session in the ``abench-session-jsonl-v1`` schema::
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"session_id": str,
|
|
13
|
+
"source": {"dataset", "agent", "model", "trial"},
|
|
14
|
+
"system_prompt": str,
|
|
15
|
+
"turns": [
|
|
16
|
+
{
|
|
17
|
+
"index": int,
|
|
18
|
+
"input_delta": [{"role", "content"}], # msgs added before this turn
|
|
19
|
+
"assistant_text": str, # recorded assistant output (for trace mode)
|
|
20
|
+
"input_tokens": int, # authoritative prompt_tokens
|
|
21
|
+
"output_tokens": int, # authoritative completion_tokens
|
|
22
|
+
"cached_tokens": int, # authoritative cached prefix tokens
|
|
23
|
+
"pre_gap_ms": float # tool-wait before this turn
|
|
24
|
+
}, ...
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
This module downloads those files (resume + SHA-256 verified) and loads
|
|
29
|
+
them back as :class:`NormSession` objects. The dataset x tier catalog is
|
|
30
|
+
hardcoded below per the packaging policy: update it when a new version
|
|
31
|
+
is published.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import urllib.request
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
42
|
+
|
|
43
|
+
MODELSCOPE_REPO = "Apsara-Stack/ABench"
|
|
44
|
+
RESOLVE_TEMPLATE = (
|
|
45
|
+
"https://www.modelscope.cn/datasets/{repo}/resolve/master/{path}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Cap tool-wait gaps so a single stalled tool call cannot dominate the
|
|
49
|
+
# closed-loop schedule. The real distribution has a long tail; 30 s keeps
|
|
50
|
+
# replays tractable while preserving the "agent is idle" semantics.
|
|
51
|
+
DEFAULT_GAP_CAP_MS = 30_000.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class TierSpec:
|
|
56
|
+
"""One downloadable dataset x tier combination."""
|
|
57
|
+
|
|
58
|
+
dataset: str
|
|
59
|
+
tier: str
|
|
60
|
+
version: str
|
|
61
|
+
sessions: int
|
|
62
|
+
band: Optional[List[int]] = None
|
|
63
|
+
system_prompt_len: Optional[int] = None
|
|
64
|
+
files: tuple = () # ((repo_path, size_bytes, sha256), ...)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
return f"{self.dataset}/{self.tier}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# The packaged catalog, mirroring catalog.json on ModelScope. Tiers not
|
|
72
|
+
# listed here are not published; update this table when the mirror is.
|
|
73
|
+
TIERS: Dict[str, TierSpec] = {
|
|
74
|
+
t.name: t
|
|
75
|
+
for t in (
|
|
76
|
+
TierSpec(
|
|
77
|
+
dataset="deepswe",
|
|
78
|
+
tier="mixed",
|
|
79
|
+
version="v1.1",
|
|
80
|
+
sessions=800,
|
|
81
|
+
band=[8_000, 176_000],
|
|
82
|
+
system_prompt_len=8_192,
|
|
83
|
+
files=(
|
|
84
|
+
(
|
|
85
|
+
"data/deepswe/v1.1/mixed/data.jsonl.zst",
|
|
86
|
+
41_916_286,
|
|
87
|
+
"242179e664f883ab7a9acc5775f7cd81d1a5c3dda07ba7495257cfe44c2c652f",
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
TierSpec(
|
|
92
|
+
dataset="deepswe",
|
|
93
|
+
tier="full",
|
|
94
|
+
version="v1.1",
|
|
95
|
+
sessions=22_489,
|
|
96
|
+
system_prompt_len=8_192,
|
|
97
|
+
files=(
|
|
98
|
+
(
|
|
99
|
+
"data/deepswe/v1.1/full/part-00000-of-00004.jsonl.zst",
|
|
100
|
+
453_056_964,
|
|
101
|
+
"158d7455419c46d072e8e23eb6308999d24c158030bcd920d2fc567ed40d4c92",
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"data/deepswe/v1.1/full/part-00001-of-00004.jsonl.zst",
|
|
105
|
+
446_134_353,
|
|
106
|
+
"e07772cb2af49c0687a10c253a569ec2c608008cd022dfcdbe88a0dd8c733a55",
|
|
107
|
+
),
|
|
108
|
+
(
|
|
109
|
+
"data/deepswe/v1.1/full/part-00002-of-00004.jsonl.zst",
|
|
110
|
+
446_203_059,
|
|
111
|
+
"c40251439a7c43e90038bb5427a82f2cd5c053ba25bdaa69870f73ee8303f775",
|
|
112
|
+
),
|
|
113
|
+
(
|
|
114
|
+
"data/deepswe/v1.1/full/part-00003-of-00004.jsonl.zst",
|
|
115
|
+
479_975_752,
|
|
116
|
+
"342c4e7aa4bac5b1e350dd3eb5e533f0c30bc675e8b88158ee7898dccb387af1",
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
TierSpec(
|
|
121
|
+
dataset="lmcache",
|
|
122
|
+
tier="full",
|
|
123
|
+
version="v1",
|
|
124
|
+
sessions=767,
|
|
125
|
+
files=(
|
|
126
|
+
(
|
|
127
|
+
"data/lmcache/v1/full/data.jsonl.zst",
|
|
128
|
+
9_059_217,
|
|
129
|
+
"fd190601d4a3b690d24d5c416eb87a5e52c341f8b5658a76371e605f8e1a2ff5",
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ----------------------------------------------------------------------
|
|
138
|
+
# Data structures
|
|
139
|
+
# ----------------------------------------------------------------------
|
|
140
|
+
@dataclass
|
|
141
|
+
class NormTurn:
|
|
142
|
+
"""One recorded LLM request/response inside a trajectory."""
|
|
143
|
+
|
|
144
|
+
index: int
|
|
145
|
+
input_delta: List[Dict[str, str]]
|
|
146
|
+
assistant_text: str
|
|
147
|
+
input_tokens: int
|
|
148
|
+
output_tokens: int
|
|
149
|
+
cached_tokens: int = 0
|
|
150
|
+
pre_gap_ms: float = 0.0
|
|
151
|
+
# Recorded tool calls, kept structured in addition to being folded into
|
|
152
|
+
# ``assistant_text`` (some downstream analyses want them parsed).
|
|
153
|
+
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
156
|
+
return {
|
|
157
|
+
"index": self.index,
|
|
158
|
+
"input_delta": self.input_delta,
|
|
159
|
+
"assistant_text": self.assistant_text,
|
|
160
|
+
"input_tokens": self.input_tokens,
|
|
161
|
+
"output_tokens": self.output_tokens,
|
|
162
|
+
"cached_tokens": self.cached_tokens,
|
|
163
|
+
"pre_gap_ms": self.pre_gap_ms,
|
|
164
|
+
"tool_calls": self.tool_calls,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_dict(cls, d: Dict[str, Any]) -> "NormTurn":
|
|
169
|
+
return cls(
|
|
170
|
+
index=d["index"],
|
|
171
|
+
input_delta=d["input_delta"],
|
|
172
|
+
assistant_text=d.get("assistant_text", ""),
|
|
173
|
+
input_tokens=d.get("input_tokens", 0),
|
|
174
|
+
output_tokens=d.get("output_tokens", 0),
|
|
175
|
+
cached_tokens=d.get("cached_tokens", 0),
|
|
176
|
+
pre_gap_ms=d.get("pre_gap_ms", 0.0),
|
|
177
|
+
tool_calls=d.get("tool_calls") or [],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class NormSession:
|
|
183
|
+
"""A normalized agent trajectory ready for replay."""
|
|
184
|
+
|
|
185
|
+
session_id: str
|
|
186
|
+
system_prompt: str
|
|
187
|
+
turns: List[NormTurn]
|
|
188
|
+
source: Dict[str, Any] = field(default_factory=dict)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def num_turns(self) -> int:
|
|
192
|
+
return len(self.turns)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def peak_input_tokens(self) -> int:
|
|
196
|
+
return max((t.input_tokens for t in self.turns), default=0)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def total_output_tokens(self) -> int:
|
|
200
|
+
return sum(t.output_tokens for t in self.turns)
|
|
201
|
+
|
|
202
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
203
|
+
return {
|
|
204
|
+
"session_id": self.session_id,
|
|
205
|
+
"source": self.source,
|
|
206
|
+
"system_prompt": self.system_prompt,
|
|
207
|
+
"turns": [t.to_dict() for t in self.turns],
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def from_dict(cls, d: Dict[str, Any]) -> "NormSession":
|
|
212
|
+
return cls(
|
|
213
|
+
session_id=d["session_id"],
|
|
214
|
+
system_prompt=d.get("system_prompt", ""),
|
|
215
|
+
turns=[NormTurn.from_dict(t) for t in d.get("turns", [])],
|
|
216
|
+
source=d.get("source", {}),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ----------------------------------------------------------------------
|
|
221
|
+
# Download (ModelScope)
|
|
222
|
+
# ----------------------------------------------------------------------
|
|
223
|
+
def resolve_url(repo_path: str, repo: str = MODELSCOPE_REPO) -> str:
|
|
224
|
+
"""Direct-download URL for one file of the ABench ModelScope mirror."""
|
|
225
|
+
return RESOLVE_TEMPLATE.format(repo=repo, path=repo_path)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _sha256_file(path: str) -> str:
|
|
229
|
+
h = hashlib.sha256()
|
|
230
|
+
with open(path, "rb") as f:
|
|
231
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
232
|
+
h.update(chunk)
|
|
233
|
+
return h.hexdigest()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def download_tier(
|
|
237
|
+
spec: TierSpec,
|
|
238
|
+
out_dir: str,
|
|
239
|
+
repo: str = MODELSCOPE_REPO,
|
|
240
|
+
verbose: bool = True,
|
|
241
|
+
) -> List[str]:
|
|
242
|
+
"""Download every file of ``spec`` into ``out_dir`` (resume + verify).
|
|
243
|
+
|
|
244
|
+
Each file lands under its relative name (subdirectories included).
|
|
245
|
+
Existing files with the expected size and SHA-256 are kept, so an
|
|
246
|
+
interrupted download resumes instead of restarting. Returns the list
|
|
247
|
+
of local file paths.
|
|
248
|
+
"""
|
|
249
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
250
|
+
done: List[str] = []
|
|
251
|
+
for repo_path, size, sha in spec.files:
|
|
252
|
+
dst = os.path.join(out_dir, os.path.basename(repo_path))
|
|
253
|
+
if os.path.exists(dst) and os.path.getsize(dst) == size:
|
|
254
|
+
if _sha256_file(dst) == sha:
|
|
255
|
+
done.append(dst)
|
|
256
|
+
if verbose:
|
|
257
|
+
print(f"[data] cached: {os.path.basename(dst)}")
|
|
258
|
+
continue
|
|
259
|
+
os.remove(dst) # corrupt partial -> redownload
|
|
260
|
+
url = resolve_url(repo_path, repo=repo)
|
|
261
|
+
if verbose:
|
|
262
|
+
print(
|
|
263
|
+
f"[data] downloading {os.path.basename(dst)} "
|
|
264
|
+
f"({size / 1e6:.1f} MB)"
|
|
265
|
+
)
|
|
266
|
+
tmp = dst + ".part"
|
|
267
|
+
_download_file(url, tmp, expected_size=size)
|
|
268
|
+
got = _sha256_file(tmp)
|
|
269
|
+
if got != sha:
|
|
270
|
+
os.remove(tmp)
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
f"sha256 mismatch for {repo_path}: expected {sha}, got {got}"
|
|
273
|
+
)
|
|
274
|
+
os.replace(tmp, dst)
|
|
275
|
+
done.append(dst)
|
|
276
|
+
return done
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _download_file(url: str, dst: str, expected_size: int) -> None:
|
|
280
|
+
"""Stream ``url`` to ``dst`` with a progress line, resuming if partial."""
|
|
281
|
+
resume_from = 0
|
|
282
|
+
if os.path.exists(dst):
|
|
283
|
+
resume_from = os.path.getsize(dst)
|
|
284
|
+
if resume_from >= expected_size:
|
|
285
|
+
os.remove(dst)
|
|
286
|
+
resume_from = 0
|
|
287
|
+
req = urllib.request.Request(url)
|
|
288
|
+
if resume_from:
|
|
289
|
+
req.add_header("Range", f"bytes={resume_from}-")
|
|
290
|
+
mode = "ab" if resume_from else "wb"
|
|
291
|
+
with urllib.request.urlopen(req, timeout=60) as r, open(dst, mode) as f:
|
|
292
|
+
total = int(r.headers.get("Content-Length", 0)) + resume_from
|
|
293
|
+
got = resume_from
|
|
294
|
+
last_pct = -10
|
|
295
|
+
while True:
|
|
296
|
+
chunk = r.read(1 << 20)
|
|
297
|
+
if not chunk:
|
|
298
|
+
break
|
|
299
|
+
f.write(chunk)
|
|
300
|
+
got += len(chunk)
|
|
301
|
+
if total:
|
|
302
|
+
pct = int(got * 100 / total)
|
|
303
|
+
if pct >= last_pct + 10:
|
|
304
|
+
last_pct = pct
|
|
305
|
+
print(f" [{pct:3d}%] {got / 1e6:.1f}/{total / 1e6:.1f} MB")
|
|
306
|
+
if got != expected_size:
|
|
307
|
+
raise RuntimeError(
|
|
308
|
+
f"download incomplete: {got} bytes, expected {expected_size}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def write_tier_manifest(
|
|
313
|
+
spec: TierSpec,
|
|
314
|
+
out_dir: str,
|
|
315
|
+
files: Iterable[str],
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Write the ``manifest.json`` the run side reads for band knobs."""
|
|
318
|
+
manifest = {
|
|
319
|
+
"dataset": spec.dataset,
|
|
320
|
+
"tier": spec.tier,
|
|
321
|
+
"version": spec.version,
|
|
322
|
+
"n_sessions": spec.sessions,
|
|
323
|
+
"band": spec.band,
|
|
324
|
+
"system_prompt_len": spec.system_prompt_len,
|
|
325
|
+
"files": [
|
|
326
|
+
{"path": p, "size_bytes": os.path.getsize(p), "sha256": _sha256_file(p)}
|
|
327
|
+
for p in files
|
|
328
|
+
],
|
|
329
|
+
}
|
|
330
|
+
with open(os.path.join(out_dir, "manifest.json"), "w") as f:
|
|
331
|
+
json.dump(manifest, f, indent=1)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ----------------------------------------------------------------------
|
|
335
|
+
# Load
|
|
336
|
+
# ----------------------------------------------------------------------
|
|
337
|
+
def _open_jsonl_zst(path: str):
|
|
338
|
+
"""Open a (possibly zstd-compressed) JSONL file as a line iterator.
|
|
339
|
+
|
|
340
|
+
Prefers the ``zstandard`` package; falls back to the ``zstd`` CLI so
|
|
341
|
+
a bare install (aiohttp only) can still read downloaded data.
|
|
342
|
+
"""
|
|
343
|
+
if not path.endswith(".zst"):
|
|
344
|
+
return open(path, "r", encoding="utf-8")
|
|
345
|
+
try:
|
|
346
|
+
import zstandard as zstd
|
|
347
|
+
|
|
348
|
+
return zstd.open(path, "rt", encoding="utf-8")
|
|
349
|
+
except ImportError:
|
|
350
|
+
import subprocess
|
|
351
|
+
|
|
352
|
+
proc = subprocess.Popen(
|
|
353
|
+
["zstd", "-dc", path],
|
|
354
|
+
stdout=subprocess.PIPE,
|
|
355
|
+
stderr=subprocess.DEVNULL,
|
|
356
|
+
)
|
|
357
|
+
# Wrap the byte stream as text; process cleanup happens at GC/exhaust.
|
|
358
|
+
return _TextProcessReader(proc)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class _TextProcessReader:
|
|
362
|
+
"""Minimal text-mode adapter over a subprocess stdout pipe."""
|
|
363
|
+
|
|
364
|
+
def __init__(self, proc):
|
|
365
|
+
import io
|
|
366
|
+
|
|
367
|
+
self._proc = proc
|
|
368
|
+
self._io = io.TextIOWrapper(proc.stdout, encoding="utf-8")
|
|
369
|
+
|
|
370
|
+
def __iter__(self):
|
|
371
|
+
return self._io.__iter__()
|
|
372
|
+
|
|
373
|
+
def close(self):
|
|
374
|
+
try:
|
|
375
|
+
self._io.close()
|
|
376
|
+
finally:
|
|
377
|
+
if self._proc.poll() is None:
|
|
378
|
+
self._proc.terminate()
|
|
379
|
+
|
|
380
|
+
def __enter__(self):
|
|
381
|
+
return self
|
|
382
|
+
|
|
383
|
+
def __exit__(self, *exc):
|
|
384
|
+
self.close()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def iter_jsonl_sessions(paths: Iterable[str]):
|
|
388
|
+
"""Yield one raw session dict per JSONL line across ``paths``."""
|
|
389
|
+
for path in paths:
|
|
390
|
+
with _open_jsonl_zst(path) as f:
|
|
391
|
+
for line in f:
|
|
392
|
+
line = line.strip()
|
|
393
|
+
if line:
|
|
394
|
+
yield json.loads(line)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def load_sessions(data_dir: str) -> List[NormSession]:
|
|
398
|
+
"""Load every session from a downloaded tier directory.
|
|
399
|
+
|
|
400
|
+
Accepts both the v0.2 layout (``*.jsonl.zst`` JSONL files written by
|
|
401
|
+
``python3 -m abench data``) and a directory of per-session ``*.json`` files.
|
|
402
|
+
"""
|
|
403
|
+
sessions: List[NormSession] = []
|
|
404
|
+
if not os.path.isdir(data_dir):
|
|
405
|
+
return sessions
|
|
406
|
+
names = sorted(os.listdir(data_dir))
|
|
407
|
+
jsonl = [
|
|
408
|
+
os.path.join(data_dir, n)
|
|
409
|
+
for n in names
|
|
410
|
+
if n.endswith(".jsonl") or n.endswith(".jsonl.zst")
|
|
411
|
+
]
|
|
412
|
+
if jsonl:
|
|
413
|
+
for raw in iter_jsonl_sessions(jsonl):
|
|
414
|
+
sessions.append(NormSession.from_dict(raw))
|
|
415
|
+
return sessions
|
|
416
|
+
for fn in names:
|
|
417
|
+
if not fn.endswith(".json") or fn == "manifest.json":
|
|
418
|
+
continue
|
|
419
|
+
with open(os.path.join(data_dir, fn)) as f:
|
|
420
|
+
sessions.append(NormSession.from_dict(json.load(f)))
|
|
421
|
+
return sessions
|