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