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.
@@ -0,0 +1,553 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright (c) 2026, Alibaba Group and its affiliates
3
+
4
+ """Closed-loop agentic replay engine.
5
+
6
+ Implements the concurrency model from the benchmark design:
7
+
8
+ * A fixed number of **active sessions** ``C`` is maintained. Each of the
9
+ ``C`` async workers holds exactly one session at a time and drives it
10
+ turn-by-turn (a session is strictly sequential: turn N+1 waits for
11
+ turn N to complete). When a session finishes, the worker pulls the
12
+ next one from the pool — so a short trajectory is simply *recycled
13
+ faster*, keeping the active count pinned at ``C`` without any turn
14
+ truncation.
15
+
16
+ * ``--duration`` (optional) turns the pool into a looping source and
17
+ stops dispatching new sessions once the deadline passes; in-flight
18
+ sessions are allowed to finish. This removes the drain phase from the
19
+ measurement window.
20
+
21
+ * **Tool waits** (``pre_gap_ms``) are honored *between* turns but are not
22
+ counted in per-turn latency — the model is idle, not the client.
23
+
24
+ * Every turn's decode length is pinned by the output policy — the
25
+ recorded ``completion_tokens`` when replaying a trace, a constant when
26
+ the workload is synthetic — and sent as ``max_completion_tokens`` with
27
+ ``ignore_eos``. The model's own EOS never decides how much work a turn
28
+ does, which is what makes two engines comparable on the same workload.
29
+
30
+ HTTP plumbing lives in ``abench.replay.transport``, server discovery and
31
+ telemetry in ``abench.serve``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import threading
38
+ import time
39
+ import traceback
40
+ from dataclasses import replace
41
+ from typing import Any, Dict, List, Optional, Tuple
42
+
43
+ import aiohttp
44
+
45
+ from abench.core.metrics import ServerMetricsSnapshot, SessionRecord, TurnRecord
46
+ from abench.core.workload import PlannedSession
47
+ from abench.replay.transport import (
48
+ _count_tokens_async,
49
+ _process_non_stream,
50
+ _process_stream,
51
+ )
52
+ from abench.serve.endpoints import get_auth_headers, server_root
53
+ from abench.serve.telemetry import _scrape_server_metrics
54
+
55
+ # ----------------------------------------------------------------------
56
+ # Shared run state (concurrency accounting + pool)
57
+ # ----------------------------------------------------------------------
58
+ class _RunState:
59
+ """Thread/async-safe dispatch state shared by all workers."""
60
+
61
+ def __init__(
62
+ self,
63
+ pool: List[PlannedSession],
64
+ loop_pool: bool,
65
+ deadline: Optional[float],
66
+ max_duration_s: Optional[float] = None,
67
+ ) -> None:
68
+ self._pool = pool
69
+ self._loop = loop_pool
70
+ self._deadline = deadline
71
+ # Runaway fuse: hard cap on total run wall clock, independent of
72
+ # any window. Guards against a wedged server or endless retry
73
+ # dragging a run on forever; a normal run should never reach it.
74
+ self._max_deadline = (
75
+ time.perf_counter() + max_duration_s if max_duration_s else None
76
+ )
77
+ self._idx = 0
78
+ self._lock = threading.Lock()
79
+ self.active = 0
80
+ self.exhausted = False
81
+ self.planned_turns = sum(len(s.turns) for s in pool)
82
+ self.turns_done = 0
83
+ self.output_tokens = 0
84
+ # Optional tqdm handle; set by run_closed_loop when a progress bar
85
+ # is active. Workers update it per completed turn.
86
+ self.pbar = None
87
+ self.sessions_done = 0
88
+ self.sessions_truncated = 0
89
+
90
+ def effective_deadline(self) -> Optional[float]:
91
+ """Earliest of the --duration window and the --max-duration cap."""
92
+ if self._deadline is not None and self._max_deadline is not None:
93
+ return min(self._deadline, self._max_deadline)
94
+ return self._deadline if self._deadline is not None else self._max_deadline
95
+
96
+ def next_session(self) -> Optional[PlannedSession]:
97
+ d = self.effective_deadline()
98
+ if d is not None and time.perf_counter() >= d:
99
+ return None
100
+ with self._lock:
101
+ if self._idx >= len(self._pool):
102
+ if not self._loop:
103
+ self.exhausted = True
104
+ return None
105
+ self._idx = 0
106
+ sess = self._pool[self._idx]
107
+ self._idx += 1
108
+ return sess
109
+
110
+
111
+ # ----------------------------------------------------------------------
112
+ # Single-session driver
113
+ # ----------------------------------------------------------------------
114
+ def _turn_request_timeout(
115
+ turn_timeout_s: float, deadline: Optional[float], now: float
116
+ ) -> Optional[aiohttp.ClientTimeout]:
117
+ """Per-request cap: the tighter of the turn timeout and the remaining
118
+ ``--max-duration`` budget. The fuse's between-turn check cannot stop a
119
+ hung in-flight request; without this, one wedged turn holds the run
120
+ hostage far past the deadline. ``None``
121
+ means "no deadline": fall back to the session's default timeout."""
122
+ if deadline is None:
123
+ return None
124
+ remaining = deadline - now
125
+ if remaining <= 0:
126
+ return None
127
+ return aiohttp.ClientTimeout(total=min(turn_timeout_s, remaining))
128
+
129
+
130
+ async def _run_session(
131
+ sess: PlannedSession,
132
+ state: _RunState,
133
+ api_url: str,
134
+ model: str,
135
+ session: aiohttp.ClientSession,
136
+ tokenizer: Optional[Any],
137
+ content_mode: str,
138
+ disable_stream: bool,
139
+ extra_body: Dict[str, Any],
140
+ respect_gaps: bool,
141
+ gap_scale: float = 1.0,
142
+ ignore_eos: bool = True,
143
+ worker_header: Optional[str] = None,
144
+ session_timeout_s: float = 0.0,
145
+ extra_headers: Optional[Dict[str, str]] = None,
146
+ extra_request_body: Optional[Dict[str, Any]] = None,
147
+ backend: str = "sglang",
148
+ max_model_len: Optional[int] = None,
149
+ turn_timeout_s: float = 3600.0,
150
+ ) -> SessionRecord:
151
+ rec = SessionRecord(
152
+ session_id=sess.session_id,
153
+ source_model=str(sess.metadata.get("model", "")),
154
+ )
155
+
156
+ session_start = time.perf_counter()
157
+
158
+ messages: List[Dict[str, str]] = []
159
+ # Running client-side estimate of the prompt length. Each piece of
160
+ # text is encoded exactly once (incremental), and the server-reported
161
+ # ``usage.prompt_tokens`` overrides the estimate whenever available.
162
+ input_len_est = 0
163
+ if sess.system_prompt:
164
+ messages.append({"role": "system", "content": sess.system_prompt})
165
+ input_len_est += await _count_tokens_async(sess.system_prompt, tokenizer)
166
+
167
+ for turn in sess.turns:
168
+ # Timed runs: truncate in-flight sessions once the window closes
169
+ # instead of draining their whole trajectory. Drain turns are
170
+ # excluded from steady-window statistics anyway, so keeping them
171
+ # only stretches the wall clock (a timed run can last 5-10x the
172
+ # window on long trajectories with tool gaps).
173
+ d = state.effective_deadline()
174
+ if d is not None and time.perf_counter() > d:
175
+ rec.truncated = True
176
+ break
177
+
178
+ # Per-session timeout: stop dispatching new turns once the budget
179
+ # is spent. Already-completed turns stay; the session is marked
180
+ # truncated so it is excluded from trajectory stats.
181
+ if session_timeout_s > 0 and (time.perf_counter() - session_start) >= session_timeout_s:
182
+ rec.truncated = True
183
+ break
184
+
185
+ # Client-side context guard: estimated prompt plus the pinned
186
+ # completion must fit the model's context window, else the server
187
+ # rejects the turn with an HTTP 400. Later turns only grow, so
188
+ # stop the session instead of burning guaranteed failures.
189
+ if (
190
+ max_model_len is not None
191
+ and input_len_est + turn.max_new_tokens > max_model_len
192
+ ):
193
+ rec.truncated = True
194
+ rec.ctx_guard_stop = True
195
+ break
196
+
197
+ if respect_gaps and turn.pre_gap_ms > 0:
198
+ await asyncio.sleep(turn.pre_gap_ms * gap_scale / 1000.0)
199
+
200
+ for m in turn.input_delta:
201
+ # Tool observations are recorded with role="tool" but carry no
202
+ # tool_call_id, which strict OpenAI-schema validators (routers)
203
+ # reject. Role does not affect prefill/decode cost, so coerce any
204
+ # non-standard role to "user" for schema-safe replay.
205
+ role = m["role"] if m["role"] in ("system", "user", "assistant") else "user"
206
+ messages.append({"role": role, "content": m["content"]})
207
+ input_len_est += await _count_tokens_async(m["content"], tokenizer)
208
+
209
+ payload: Dict[str, Any] = {
210
+ "model": model,
211
+ "messages": messages,
212
+ "max_completion_tokens": turn.max_new_tokens,
213
+ "stream": not disable_stream,
214
+ }
215
+ if ignore_eos:
216
+ payload["ignore_eos"] = True
217
+ if not disable_stream:
218
+ payload["stream_options"] = {"include_usage": True}
219
+ if backend == "sglang":
220
+ # Off by default server-side; without it the device/host split
221
+ # of prefix-cache hits is not reported at all. SGLang-only:
222
+ # other backends may reject unknown request fields outright.
223
+ payload["return_cached_tokens_details"] = True
224
+ payload.update(extra_body or {})
225
+ # --extra-request-body goes last so it can override anything above.
226
+ payload.update(extra_request_body or {})
227
+
228
+ headers = {"Content-Type": "application/json"}
229
+ headers.update(get_auth_headers())
230
+ headers.update(extra_headers or {})
231
+ if sess.routing_key:
232
+ headers["X-SMG-Routing-Key"] = sess.routing_key
233
+
234
+ tr = TurnRecord(
235
+ session_id=sess.session_id,
236
+ turn_index=turn.index,
237
+ input_len=input_len_est,
238
+ active_at_dispatch=state.active,
239
+ recorded_output_len=turn.recorded_output_tokens,
240
+ )
241
+ start = time.perf_counter()
242
+ tr.dispatch_time = start
243
+ tr.dispatch_epoch = time.time() # exported as start_ts (epoch s)
244
+ gen_text = ""
245
+ try:
246
+ req_timeout = _turn_request_timeout(
247
+ turn_timeout_s, state.effective_deadline(), time.perf_counter()
248
+ )
249
+ async with session.post(
250
+ api_url, json=payload, headers=headers, timeout=req_timeout
251
+ ) as resp:
252
+ if resp.status != 200:
253
+ body = await resp.text()
254
+ tr.success = False
255
+ tr.error = f"HTTP {resp.status}: {body[:200]}"
256
+ else:
257
+ if disable_stream:
258
+ gen_text, ttft, itl, usage = await _process_non_stream(
259
+ resp, start
260
+ )
261
+ else:
262
+ gen_text, ttft, itl, usage = await _process_stream(
263
+ resp, start
264
+ )
265
+ tr.ttft = ttft
266
+ tr.itl = itl
267
+ tr.server_cached_tokens = usage["cached_tokens"]
268
+ tr.cached_device_tokens = usage["cached_device_tokens"]
269
+ tr.cached_host_tokens = usage["cached_host_tokens"]
270
+ # Server-reported token counts are authoritative (they
271
+ # include chat-template overhead the client can't see).
272
+ if usage["prompt_tokens"] > 0:
273
+ tr.input_len = usage["prompt_tokens"]
274
+ if usage["completion_tokens"] > 0:
275
+ tr.output_len = usage["completion_tokens"]
276
+ else:
277
+ tr.output_len = await _count_tokens_async(
278
+ gen_text, tokenizer
279
+ )
280
+ tr.success = bool(gen_text) or tr.output_len > 0
281
+ if worker_header:
282
+ worker = resp.headers.get(worker_header)
283
+ if worker:
284
+ rec.assigned_workers.append(worker)
285
+ except Exception as exc: # noqa: BLE001
286
+ tr.success = False
287
+ tr.error = f"{type(exc).__name__}: {exc} | " + traceback.format_exc(
288
+ limit=1
289
+ )
290
+ tr.complete_time = time.perf_counter()
291
+ tr.complete_epoch = time.time() # exported as end_ts (epoch s)
292
+ tr.latency = tr.complete_time - start
293
+ rec.turns.append(tr)
294
+ state.turns_done += 1
295
+ state.output_tokens += tr.output_len
296
+ if state.pbar is not None:
297
+ state.pbar.update(1)
298
+
299
+ # Advance the running context with the assistant slot.
300
+ if content_mode == "model" and tr.success:
301
+ messages.append({"role": "assistant", "content": gen_text})
302
+ input_len_est += tr.output_len
303
+ else:
304
+ messages.append({"role": "assistant", "content": turn.assistant_text})
305
+ input_len_est += await _count_tokens_async(
306
+ turn.assistant_text, tokenizer
307
+ )
308
+
309
+ return rec
310
+
311
+
312
+ # ----------------------------------------------------------------------
313
+ # Closed-loop orchestration
314
+ # ----------------------------------------------------------------------
315
+ async def run_closed_loop(
316
+ pool: List[PlannedSession],
317
+ api_url: str,
318
+ model: str,
319
+ max_concurrency: int,
320
+ tokenizer: Optional[Any],
321
+ content_mode: str = "trace",
322
+ disable_stream: bool = False,
323
+ duration_s: Optional[float] = None,
324
+ respect_gaps: bool = True,
325
+ gap_scale: float = 1.0,
326
+ extra_body: Optional[Dict[str, Any]] = None,
327
+ worker_header: Optional[str] = None,
328
+ verbose: bool = False,
329
+ progress_interval: float = 30.0,
330
+ ignore_eos: bool = True,
331
+ turn_timeout_s: float = 3600.0,
332
+ metrics_interval: float = 5.0,
333
+ session_timeout_s: float = 0.0,
334
+ extra_headers: Optional[Dict[str, str]] = None,
335
+ extra_request_body: Optional[Dict[str, Any]] = None,
336
+ backend: str = "sglang",
337
+ max_duration_s: Optional[float] = None,
338
+ disable_tqdm: bool = False,
339
+ max_model_len: Optional[int] = None,
340
+ ) -> Tuple[List[SessionRecord], List[ServerMetricsSnapshot]]:
341
+ """Drive ``pool`` with ``concurrency`` active sessions.
342
+
343
+ When ``duration_s`` is set the pool loops and dispatch stops at the
344
+ deadline; otherwise every session is played exactly once.
345
+ ``max_duration_s`` is a hard cap on top of either mode (runaway fuse).
346
+
347
+ ``backend`` gates the observation layer: only ``sglang`` sends
348
+ ``return_cached_tokens_details`` and scrapes ``/metrics`` with the
349
+ ``sglang:`` gauge names; ``vllm`` scrapes with ``vllm:`` names;
350
+ ``openai`` skips scraping entirely.
351
+
352
+ Returns ``(session_records, server_metrics_snapshots)``. The snapshots
353
+ list is empty when the server does not expose ``/metrics``.
354
+ """
355
+ deadline = (time.perf_counter() + duration_s) if duration_s else None
356
+ state = _RunState(
357
+ pool,
358
+ loop_pool=duration_s is not None,
359
+ deadline=deadline,
360
+ max_duration_s=max_duration_s,
361
+ )
362
+ results: List[SessionRecord] = []
363
+ results_lock = asyncio.Lock()
364
+
365
+ connector = aiohttp.TCPConnector(limit=max(max_concurrency * 2, 32), ssl=False)
366
+ # Applies per request: a hung turn frees its slot after this long instead
367
+ # of holding it for the rest of the run.
368
+ timeout = aiohttp.ClientTimeout(total=turn_timeout_s)
369
+
370
+ async def progress() -> None:
371
+ """Heartbeat so a multi-hour run is distinguishable from a stall."""
372
+ t0 = time.perf_counter()
373
+ while True:
374
+ await asyncio.sleep(heartbeat_interval)
375
+ el = time.perf_counter() - t0
376
+ rate = state.output_tokens / el if el > 0 else 0.0
377
+ line = (
378
+ f"[progress] {el / 60:6.1f}min "
379
+ f"turns {state.turns_done}/{state.planned_turns} "
380
+ f"({100.0 * state.turns_done / max(1, state.planned_turns):4.1f}%) "
381
+ f"sessions done={state.sessions_done} trunc={state.sessions_truncated} "
382
+ f"active={state.active}/{max_concurrency} "
383
+ f"out={rate:7.1f} tok/s"
384
+ )
385
+ if state.planned_turns and state.turns_done:
386
+ remaining = state.planned_turns - state.turns_done
387
+ eta_min = remaining / (state.turns_done / el) / 60.0
388
+ line += f" eta~{eta_min:5.1f}min"
389
+ print(line, flush=True)
390
+
391
+ server_snapshots: List[ServerMetricsSnapshot] = []
392
+ metrics_url = server_root(api_url) + "/metrics"
393
+
394
+ # Optional turn-level progress bar. tqdm is not a hard dependency: with
395
+ # it absent (or disable_tqdm set) the periodic heartbeat above remains
396
+ # the only progress signal, exactly as before.
397
+ pbar = None
398
+ if not disable_tqdm:
399
+ try:
400
+ from tqdm.auto import tqdm
401
+
402
+ pbar = tqdm(
403
+ total=state.planned_turns,
404
+ unit="turn",
405
+ dynamic_ncols=True,
406
+ bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]",
407
+ )
408
+ except ImportError:
409
+ pbar = None
410
+ state.pbar = pbar
411
+ # With the bar live the heartbeat is redundant visual noise unless the
412
+ # interval is explicitly raised; keep it but at a coarser cadence.
413
+ heartbeat_interval = progress_interval if pbar is None else max(
414
+ progress_interval, 300.0
415
+ )
416
+
417
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as http:
418
+
419
+ async def worker(wid: int) -> None:
420
+ while True:
421
+ sess = state.next_session()
422
+ if sess is None:
423
+ return
424
+ state.active += 1
425
+ try:
426
+ rec = await _run_session(
427
+ sess=sess,
428
+ state=state,
429
+ api_url=api_url,
430
+ model=model,
431
+ session=http,
432
+ tokenizer=tokenizer,
433
+ content_mode=content_mode,
434
+ disable_stream=disable_stream,
435
+ extra_body=extra_body or {},
436
+ respect_gaps=respect_gaps,
437
+ gap_scale=gap_scale,
438
+ ignore_eos=ignore_eos,
439
+ worker_header=worker_header,
440
+ session_timeout_s=session_timeout_s,
441
+ extra_headers=extra_headers,
442
+ extra_request_body=extra_request_body,
443
+ backend=backend,
444
+ max_model_len=max_model_len,
445
+ turn_timeout_s=turn_timeout_s,
446
+ )
447
+ finally:
448
+ state.active -= 1
449
+ if rec.truncated:
450
+ state.sessions_truncated += 1
451
+ else:
452
+ state.sessions_done += 1
453
+ async with results_lock:
454
+ results.append(rec)
455
+ if verbose:
456
+ print(
457
+ f"[w{wid}] {sess.session_id[:32]} "
458
+ f"turns={len(rec.turns)} active={state.active}"
459
+ )
460
+
461
+ workers = [asyncio.create_task(worker(i)) for i in range(max_concurrency)]
462
+ reporter = (
463
+ asyncio.create_task(progress()) if progress_interval > 0 else None
464
+ )
465
+ # The generic openai backend has no known /metrics gauges to read.
466
+ scraper = (
467
+ asyncio.create_task(
468
+ _scrape_server_metrics(
469
+ http,
470
+ metrics_url,
471
+ metrics_interval,
472
+ server_snapshots,
473
+ verbose,
474
+ backend=backend,
475
+ headers=get_auth_headers(),
476
+ )
477
+ )
478
+ if metrics_interval > 0 and backend in ("sglang", "vllm")
479
+ else None
480
+ )
481
+ try:
482
+ await asyncio.gather(*workers)
483
+ finally:
484
+ if reporter is not None:
485
+ reporter.cancel()
486
+ if scraper is not None:
487
+ scraper.cancel()
488
+ if pbar is not None:
489
+ pbar.close()
490
+
491
+ if max_model_len is not None:
492
+ stopped = sum(1 for r in results if r.ctx_guard_stop)
493
+ if stopped:
494
+ print(
495
+ f"[ctx-guard] stopped {stopped} session(s) early: estimated "
496
+ f"prompt + pinned completion exceeded the model context "
497
+ f"({max_model_len} tokens)",
498
+ flush=True,
499
+ )
500
+ return results, server_snapshots
501
+
502
+
503
+ # Warmup exists to prime the shared prefix and surface config errors, not
504
+ # to pre-run whole trajectories: the prefix-cache benefit concentrates in
505
+ # the first blocks, and a full long session replayed at concurrency 1 can
506
+ # outlast the measured run and hand that session a fully warm cache into
507
+ # the measurement (found in large-scale real-server testing).
508
+ WARMUP_MAX_TURNS = 3
509
+
510
+
511
+ async def warmup(
512
+ pool: List[PlannedSession],
513
+ api_url: str,
514
+ model: str,
515
+ tokenizer: Optional[Any],
516
+ n: int,
517
+ disable_stream: bool = False,
518
+ extra_headers: Optional[Dict[str, str]] = None,
519
+ extra_request_body: Optional[Dict[str, Any]] = None,
520
+ backend: str = "sglang",
521
+ max_duration_s: Optional[float] = None,
522
+ ) -> None:
523
+ """Play the first ``n`` sessions once to populate the prefix cache.
524
+
525
+ Each session is truncated to its first ``WARMUP_MAX_TURNS`` turns and
526
+ the whole phase is bounded by ``max_duration_s`` (the same runaway fuse
527
+ the measured run gets), so warmup can never dominate wall clock.
528
+ """
529
+ if n <= 0:
530
+ return
531
+ trimmed: List[PlannedSession] = []
532
+ for sess in pool[:n]:
533
+ if len(sess.turns) > WARMUP_MAX_TURNS:
534
+ sess = replace(sess, turns=sess.turns[:WARMUP_MAX_TURNS])
535
+ trimmed.append(sess)
536
+ await run_closed_loop(
537
+ pool=trimmed,
538
+ api_url=api_url,
539
+ model=model,
540
+ max_concurrency=min(n, len(pool)) or 1,
541
+ tokenizer=tokenizer,
542
+ content_mode="trace",
543
+ disable_stream=disable_stream,
544
+ respect_gaps=False,
545
+ # Warmup runs at its own tiny concurrency over a handful of
546
+ # sessions; reporting its progress would look like the real run's.
547
+ progress_interval=0,
548
+ metrics_interval=0, # no scraping during warmup
549
+ extra_headers=extra_headers,
550
+ extra_request_body=extra_request_body,
551
+ backend=backend,
552
+ max_duration_s=max_duration_s,
553
+ )