benchmaker 0.1.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.
- benchmaker/__init__.py +152 -0
- benchmaker/bundle.py +193 -0
- benchmaker/cli.py +382 -0
- benchmaker/collect.py +178 -0
- benchmaker/config.py +448 -0
- benchmaker/env.py +87 -0
- benchmaker/load.py +326 -0
- benchmaker/metrics.py +234 -0
- benchmaker/monitors.py +228 -0
- benchmaker/runner.py +275 -0
- benchmaker/trace.py +217 -0
- benchmaker/types.py +98 -0
- benchmaker/workloads/__init__.py +53 -0
- benchmaker/workloads/agent.py +308 -0
- benchmaker/workloads/base.py +79 -0
- benchmaker/workloads/datasets.py +156 -0
- benchmaker/workloads/eval.py +504 -0
- benchmaker/workloads/hf.py +382 -0
- benchmaker/workloads/http.py +77 -0
- benchmaker/workloads/llm.py +258 -0
- benchmaker/workloads/sandbox.py +470 -0
- benchmaker-0.1.0.dist-info/METADATA +214 -0
- benchmaker-0.1.0.dist-info/RECORD +26 -0
- benchmaker-0.1.0.dist-info/WHEEL +5 -0
- benchmaker-0.1.0.dist-info/entry_points.txt +2 -0
- benchmaker-0.1.0.dist-info/top_level.txt +1 -0
benchmaker/trace.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Record and replay of request traces for deterministic reproduction.
|
|
2
|
+
|
|
3
|
+
A *trace* is a JSONL file where each line captures one fired request:
|
|
4
|
+
|
|
5
|
+
{"t_rel": 0.123, "method": "POST", "url": "...", "headers": {...},
|
|
6
|
+
"params": {...}, "json": {...}, "timeout_s": null, "meta": {...}}
|
|
7
|
+
|
|
8
|
+
`t_rel` is the time (seconds) between bench start and the moment the request
|
|
9
|
+
went out — captured *after* pre-hooks, *before* transport. Binary bodies are
|
|
10
|
+
stored under `body_b64` (base64). The recorder is a runner-level feature; the
|
|
11
|
+
replay side composes through the standard `workload_type + workload + load`
|
|
12
|
+
triple:
|
|
13
|
+
|
|
14
|
+
* `ReplayWorkloadType` — `make_request` rebuilds a `Request` from a row.
|
|
15
|
+
* `TraceWorkload` — yields rows in order.
|
|
16
|
+
* `TracePacedLoad` — emits one ticket at each row's `t_rel`
|
|
17
|
+
(optionally sped up / slowed down by `speed`).
|
|
18
|
+
|
|
19
|
+
`build_config` wires all three from a single `replay:` block, so reproducing a
|
|
20
|
+
run is just `replay: {path: ..., speed: 1.0}` plus the same `correctness:` (if
|
|
21
|
+
grading is desired).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import time
|
|
31
|
+
from typing import Any, Optional
|
|
32
|
+
|
|
33
|
+
from benchmaker.load import LoadModel
|
|
34
|
+
from benchmaker.types import Request
|
|
35
|
+
from benchmaker.workloads.base import WorkloadType
|
|
36
|
+
from benchmaker.workloads.datasets import Workload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
# Wire format
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _safe_meta(meta: dict) -> dict:
|
|
45
|
+
out: dict[str, Any] = {}
|
|
46
|
+
for k, v in meta.items():
|
|
47
|
+
try:
|
|
48
|
+
json.dumps(v)
|
|
49
|
+
out[k] = v
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
out[k] = repr(v)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def request_to_row(t_rel: float, req: Request) -> dict:
|
|
56
|
+
"""Encode a `Request` as a JSON-serializable trace row."""
|
|
57
|
+
row: dict[str, Any] = {
|
|
58
|
+
"t_rel": float(t_rel),
|
|
59
|
+
"method": req.method,
|
|
60
|
+
"url": req.url,
|
|
61
|
+
"headers": dict(req.headers or {}),
|
|
62
|
+
"params": dict(req.params or {}),
|
|
63
|
+
"timeout_s": req.timeout_s,
|
|
64
|
+
"meta": _safe_meta(req.meta or {}),
|
|
65
|
+
}
|
|
66
|
+
if req.json is not None:
|
|
67
|
+
row["json"] = req.json
|
|
68
|
+
elif req.body is not None:
|
|
69
|
+
row["body_b64"] = base64.b64encode(req.body).decode("ascii")
|
|
70
|
+
return row
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def row_to_request(row: dict) -> Request:
|
|
74
|
+
"""Rebuild a `Request` from a trace row."""
|
|
75
|
+
body: Optional[bytes] = None
|
|
76
|
+
if "body_b64" in row:
|
|
77
|
+
body = base64.b64decode(row["body_b64"])
|
|
78
|
+
return Request(
|
|
79
|
+
method=row.get("method", "GET"),
|
|
80
|
+
url=row.get("url", ""),
|
|
81
|
+
headers=dict(row.get("headers") or {}),
|
|
82
|
+
params=dict(row.get("params") or {}),
|
|
83
|
+
body=body,
|
|
84
|
+
json=row.get("json"),
|
|
85
|
+
timeout_s=row.get("timeout_s"),
|
|
86
|
+
meta=dict(row.get("meta") or {}),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_trace(path: str) -> list[dict]:
|
|
91
|
+
"""Load a trace JSONL into a list of rows, sorted by `t_rel`."""
|
|
92
|
+
rows: list[dict] = []
|
|
93
|
+
with open(path) as f:
|
|
94
|
+
for line in f:
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
rows.append(json.loads(line))
|
|
99
|
+
rows.sort(key=lambda r: float(r.get("t_rel", 0.0)))
|
|
100
|
+
return rows
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# Recorder
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TraceRecorder:
|
|
109
|
+
"""Append `(t_rel, request)` rows to a JSONL file as the bench runs.
|
|
110
|
+
|
|
111
|
+
The runner calls `open()` once at the start, `record(req, fire_mono)` per
|
|
112
|
+
fired request, and `close()` once at the end. `t_rel` is computed as
|
|
113
|
+
`fire_mono - start_mono`; `start_mono` is captured by `open()` (so it
|
|
114
|
+
matches whatever clock the runner's progress logger uses).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, path: str):
|
|
118
|
+
self._path = path
|
|
119
|
+
self._f = None
|
|
120
|
+
self._lock = asyncio.Lock()
|
|
121
|
+
self._start_mono: Optional[float] = None
|
|
122
|
+
|
|
123
|
+
def open(self, start_mono: Optional[float] = None) -> None:
|
|
124
|
+
if self._f is not None:
|
|
125
|
+
return
|
|
126
|
+
d = os.path.dirname(os.path.abspath(self._path))
|
|
127
|
+
if d:
|
|
128
|
+
os.makedirs(d, exist_ok=True)
|
|
129
|
+
self._f = open(self._path, "w")
|
|
130
|
+
self._start_mono = start_mono if start_mono is not None else time.monotonic()
|
|
131
|
+
|
|
132
|
+
async def record(self, req: Request, fire_mono: float) -> None:
|
|
133
|
+
if self._f is None:
|
|
134
|
+
self.open()
|
|
135
|
+
assert self._start_mono is not None
|
|
136
|
+
row = request_to_row(fire_mono - self._start_mono, req)
|
|
137
|
+
line = json.dumps(row, default=str)
|
|
138
|
+
async with self._lock:
|
|
139
|
+
self._f.write(line + "\n")
|
|
140
|
+
self._f.flush()
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
if self._f is not None:
|
|
144
|
+
try:
|
|
145
|
+
self._f.close()
|
|
146
|
+
finally:
|
|
147
|
+
self._f = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------- #
|
|
151
|
+
# Replay: workload + workload-type + load model
|
|
152
|
+
# --------------------------------------------------------------------------- #
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TraceWorkload(Workload):
|
|
156
|
+
"""Yield trace rows (dicts) in order. Halts when the trace is exhausted."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, trace: list[dict], name: str = "trace"):
|
|
159
|
+
self.name = name
|
|
160
|
+
self._trace = list(trace)
|
|
161
|
+
self._i = 0
|
|
162
|
+
|
|
163
|
+
async def next_item(self) -> Any:
|
|
164
|
+
if self._i >= len(self._trace):
|
|
165
|
+
raise StopAsyncIteration
|
|
166
|
+
row = self._trace[self._i]
|
|
167
|
+
self._i += 1
|
|
168
|
+
return row
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ReplayWorkloadType(WorkloadType):
|
|
172
|
+
"""Rebuild a `Request` directly from a trace row.
|
|
173
|
+
|
|
174
|
+
`streaming` is set per-instance — match the workload-type used during
|
|
175
|
+
recording so the runner reads chunks the same way (otherwise stream-only
|
|
176
|
+
metrics like TTFT won't be reproduced).
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
# Recorded Requests already carry their `reference` (and any other meta the
|
|
180
|
+
# original workload put there) under `meta`. So apply_correctness should
|
|
181
|
+
# install only the post-hook — no EvalWorkloadType item-splitting wrapper.
|
|
182
|
+
handles_reference = True
|
|
183
|
+
|
|
184
|
+
def __init__(self, streaming: bool = False, name: str = "replay"):
|
|
185
|
+
self.name = name
|
|
186
|
+
self.streaming = streaming
|
|
187
|
+
|
|
188
|
+
async def make_request(self, item: Any) -> Request:
|
|
189
|
+
if not isinstance(item, dict):
|
|
190
|
+
raise TypeError(
|
|
191
|
+
f"ReplayWorkloadType expects trace rows (dict), got {type(item).__name__}"
|
|
192
|
+
)
|
|
193
|
+
return row_to_request(item)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TracePacedLoad(LoadModel):
|
|
197
|
+
"""Open-loop load model that fires one ticket per row at the recorded time.
|
|
198
|
+
|
|
199
|
+
`speed` rescales the trace: `2.0` halves all inter-arrival gaps; `0.5`
|
|
200
|
+
doubles them. Use `1.0` for an as-recorded replay.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, trace: list[dict], speed: float = 1.0):
|
|
204
|
+
if speed <= 0:
|
|
205
|
+
raise ValueError("speed must be > 0")
|
|
206
|
+
self._times = [float(r.get("t_rel", 0.0)) for r in trace]
|
|
207
|
+
self._times.sort()
|
|
208
|
+
self._speed = float(speed)
|
|
209
|
+
|
|
210
|
+
async def tickets(self):
|
|
211
|
+
start = time.monotonic()
|
|
212
|
+
for t_rel in self._times:
|
|
213
|
+
target = start + t_rel / self._speed
|
|
214
|
+
now = time.monotonic()
|
|
215
|
+
if target > now:
|
|
216
|
+
await asyncio.sleep(target - now)
|
|
217
|
+
yield None
|
benchmaker/types.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Core data types and hook protocols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Request:
|
|
11
|
+
"""An outgoing HTTP request.
|
|
12
|
+
|
|
13
|
+
`meta` is opaque user-controlled state that travels with the request and is
|
|
14
|
+
available to post-response hooks (useful for correlating prompts to results,
|
|
15
|
+
tagging variants, etc.).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
method: str = "GET"
|
|
19
|
+
url: str = ""
|
|
20
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
21
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
body: Optional[bytes] = None
|
|
23
|
+
json: Any = None
|
|
24
|
+
timeout_s: Optional[float] = None
|
|
25
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Response:
|
|
30
|
+
status: int
|
|
31
|
+
headers: Mapping[str, str]
|
|
32
|
+
body: bytes
|
|
33
|
+
elapsed_s: float
|
|
34
|
+
ok: bool
|
|
35
|
+
error: Optional[str] = None
|
|
36
|
+
# For streaming responses, the chunks captured (bytes) and per-chunk arrival times.
|
|
37
|
+
stream_chunks: Optional[list[bytes]] = None
|
|
38
|
+
stream_chunk_times: Optional[list[float]] = None # seconds since request start
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Sample:
|
|
43
|
+
"""One observation. Workloads may attach extra numeric metrics via `extra`."""
|
|
44
|
+
|
|
45
|
+
start_ts: float # wall-clock start (time.monotonic)
|
|
46
|
+
latency_s: float # total request latency
|
|
47
|
+
status: int # HTTP status (0 if connection failure)
|
|
48
|
+
ok: bool # whether the workload considers it a success
|
|
49
|
+
# Whether the request itself was delivered successfully at the transport/HTTP
|
|
50
|
+
# layer. Stays True for responses that came back 2xx/3xx even if a post-hook
|
|
51
|
+
# later flips `ok` to False (e.g. eval scorer judged the output wrong).
|
|
52
|
+
# Use this to distinguish "request failed" from "request OK but output wrong".
|
|
53
|
+
request_ok: bool = True
|
|
54
|
+
bytes_sent: int = 0
|
|
55
|
+
bytes_recv: int = 0
|
|
56
|
+
error: Optional[str] = None
|
|
57
|
+
workload: Optional[str] = None
|
|
58
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
extra: dict[str, float] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Hook signatures. Hooks may be sync or async; the runner awaits coroutines and
|
|
63
|
+
# calls plain callables directly.
|
|
64
|
+
PreRequestHook = Callable[[Request], "Request | Awaitable[Request]"]
|
|
65
|
+
PostResponseHook = Callable[
|
|
66
|
+
[Request, Response, Sample],
|
|
67
|
+
"Sample | Awaitable[Sample]",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# A `fire(req)` callable closes over the aiohttp session, applies pre-hooks,
|
|
71
|
+
# times the call, and returns a Response. A WorkloadType.run_ticket implementation
|
|
72
|
+
# may call this 0..N times per ticket.
|
|
73
|
+
FireRequest = Callable[[Request], Awaitable[Response]]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class TicketContext:
|
|
78
|
+
"""Everything a WorkloadType needs to handle a single ticket.
|
|
79
|
+
|
|
80
|
+
The runner builds one of these per ticket and hands it to
|
|
81
|
+
`WorkloadType.run_ticket`. Multi-step workload-types (e.g. sandbox lifecycle)
|
|
82
|
+
can call `fire(req)` repeatedly; the default base-class implementation calls
|
|
83
|
+
it exactly once.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
item: Any
|
|
87
|
+
start_mono: float
|
|
88
|
+
fire: FireRequest
|
|
89
|
+
pre_hooks: tuple = ()
|
|
90
|
+
post_hooks: tuple = ()
|
|
91
|
+
workload_name: str = "workload-type"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def maybe_await(value: Any) -> Any:
|
|
95
|
+
"""Await `value` if awaitable, else return it. Lets hooks be sync OR async."""
|
|
96
|
+
if hasattr(value, "__await__"):
|
|
97
|
+
return await value
|
|
98
|
+
return value
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from benchmaker.workloads.base import WorkloadType
|
|
2
|
+
from benchmaker.workloads.datasets import (
|
|
3
|
+
Workload,
|
|
4
|
+
StaticWorkload,
|
|
5
|
+
JsonlWorkload,
|
|
6
|
+
CallableWorkload,
|
|
7
|
+
IterableWorkload,
|
|
8
|
+
)
|
|
9
|
+
from benchmaker.workloads.http import HttpWorkloadType
|
|
10
|
+
from benchmaker.workloads.llm import OpenAIChatWorkloadType
|
|
11
|
+
from benchmaker.workloads.sandbox import SandboxWorkloadType
|
|
12
|
+
from benchmaker.workloads.hf import HFDatasetWorkload
|
|
13
|
+
from benchmaker.workloads.eval import (
|
|
14
|
+
EvalWorkloadType,
|
|
15
|
+
Scorer,
|
|
16
|
+
correctness_hook,
|
|
17
|
+
extract_openai_text,
|
|
18
|
+
extract_raw_text,
|
|
19
|
+
extract_text,
|
|
20
|
+
exact_match,
|
|
21
|
+
contains,
|
|
22
|
+
regex_match,
|
|
23
|
+
json_valid,
|
|
24
|
+
multiple_choice,
|
|
25
|
+
judge_llm,
|
|
26
|
+
openai_chat_judge,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"WorkloadType",
|
|
31
|
+
"Workload",
|
|
32
|
+
"StaticWorkload",
|
|
33
|
+
"JsonlWorkload",
|
|
34
|
+
"CallableWorkload",
|
|
35
|
+
"IterableWorkload",
|
|
36
|
+
"HttpWorkloadType",
|
|
37
|
+
"OpenAIChatWorkloadType",
|
|
38
|
+
"SandboxWorkloadType",
|
|
39
|
+
"HFDatasetWorkload",
|
|
40
|
+
"EvalWorkloadType",
|
|
41
|
+
"Scorer",
|
|
42
|
+
"correctness_hook",
|
|
43
|
+
"extract_openai_text",
|
|
44
|
+
"extract_raw_text",
|
|
45
|
+
"extract_text",
|
|
46
|
+
"exact_match",
|
|
47
|
+
"contains",
|
|
48
|
+
"regex_match",
|
|
49
|
+
"json_valid",
|
|
50
|
+
"multiple_choice",
|
|
51
|
+
"judge_llm",
|
|
52
|
+
"openai_chat_judge",
|
|
53
|
+
]
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""User-defined Agent workload-type.
|
|
2
|
+
|
|
3
|
+
Drives an arbitrary Python class — typically a multi-turn agent that makes its
|
|
4
|
+
own HTTP calls (to a model API, tools, a sandbox, …) — and grades the final
|
|
5
|
+
output via the standard `correctness_hook`.
|
|
6
|
+
|
|
7
|
+
Compared to the LLM workload-types:
|
|
8
|
+
|
|
9
|
+
* No request shape is dictated. The agent owns its own client(s).
|
|
10
|
+
* One "request" = one full agent run, which may be many internal calls.
|
|
11
|
+
* Per-trajectory metrics (steps, tool calls, tokens, retries, …) ride on
|
|
12
|
+
`Sample.extra` via `AgentResult.metrics`.
|
|
13
|
+
|
|
14
|
+
The expected item shape mirrors the LLM workloads: a dict with at least a
|
|
15
|
+
``prompt`` field (passed to the agent as the task), an optional ``reference``
|
|
16
|
+
(consumed by `correctness_hook`), and any extra columns the dataset carries.
|
|
17
|
+
Items are passed through to the agent verbatim — the workload-type only peels
|
|
18
|
+
``reference`` (and any ``extra_meta_keys``) into ``Sample.meta`` so the grader
|
|
19
|
+
can find them.
|
|
20
|
+
|
|
21
|
+
Example (subclassing):
|
|
22
|
+
|
|
23
|
+
from benchmaker import Agent, AgentContext, AgentResult
|
|
24
|
+
|
|
25
|
+
class MyAgent(Agent):
|
|
26
|
+
def __init__(self, model: str):
|
|
27
|
+
self.model = model
|
|
28
|
+
|
|
29
|
+
async def run(self, ctx: AgentContext) -> AgentResult:
|
|
30
|
+
task = ctx.item["prompt"]
|
|
31
|
+
output, steps = await my_pipeline(task, model=self.model)
|
|
32
|
+
return AgentResult(
|
|
33
|
+
output=output,
|
|
34
|
+
ok=True,
|
|
35
|
+
metrics={"steps": float(steps)},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
YAML:
|
|
39
|
+
|
|
40
|
+
workload_type:
|
|
41
|
+
type: agent
|
|
42
|
+
agent: 'mypkg.myagent:MyAgent'
|
|
43
|
+
agent_kwargs:
|
|
44
|
+
model: 'gpt-4o-mini'
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import inspect
|
|
50
|
+
import time
|
|
51
|
+
from abc import ABC, abstractmethod
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from typing import Any, Callable, Optional, Union
|
|
54
|
+
|
|
55
|
+
from benchmaker.types import (
|
|
56
|
+
FireRequest,
|
|
57
|
+
Request,
|
|
58
|
+
Response,
|
|
59
|
+
Sample,
|
|
60
|
+
TicketContext,
|
|
61
|
+
maybe_await,
|
|
62
|
+
)
|
|
63
|
+
from benchmaker.workloads.base import WorkloadType
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class AgentContext:
|
|
68
|
+
"""Per-task state handed to ``Agent.run``.
|
|
69
|
+
|
|
70
|
+
``fire`` is the runner's request-firing callable. When non-None, agents
|
|
71
|
+
can route their own HTTP calls through bench-maker's session + hook
|
|
72
|
+
pipeline instead of using a separate client — handy if you want the
|
|
73
|
+
runner's pre/post hooks (auth headers, request tracing) on every internal
|
|
74
|
+
call. Most agents will simply ignore it.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
item: Any
|
|
78
|
+
workload_name: str = "agent"
|
|
79
|
+
fire: Optional[FireRequest] = None
|
|
80
|
+
start_mono: float = 0.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class AgentResult:
|
|
85
|
+
"""What ``Agent.run`` returns for one task.
|
|
86
|
+
|
|
87
|
+
- ``output`` is the text the grader sees (``extract_text`` returns it as-is).
|
|
88
|
+
- ``ok`` is whether the agent considers this trajectory a success
|
|
89
|
+
(typically: did it submit?). May be flipped to False by a correctness
|
|
90
|
+
post-hook when the submission is wrong.
|
|
91
|
+
- ``request_ok`` is whether the agent ran to completion without an
|
|
92
|
+
*infrastructure* failure (model endpoint reachable, no Python exceptions,
|
|
93
|
+
…). Default True. When False the sample is bucketed as "fail" by the
|
|
94
|
+
progress logger and summary; when True but ``ok`` ends up False it's
|
|
95
|
+
bucketed as "wrong" — i.e. "agent delivered an answer, but it isn't
|
|
96
|
+
the right one." Crashed agents are auto-set to ``request_ok=False`` by
|
|
97
|
+
the workload-type (exceptions caught in ``run_ticket``).
|
|
98
|
+
- ``metrics`` lands in ``Sample.extra`` (numeric values only).
|
|
99
|
+
- ``meta`` lands in ``Sample.meta`` (arbitrary JSON-serializable values).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
output: str = ""
|
|
103
|
+
ok: bool = True
|
|
104
|
+
error: Optional[str] = None
|
|
105
|
+
request_ok: bool = True
|
|
106
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
107
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
bytes_sent: int = 0
|
|
109
|
+
bytes_recv: int = 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Agent(ABC):
|
|
113
|
+
"""Base class for user-provided agents.
|
|
114
|
+
|
|
115
|
+
Subclasses can take whatever ``__init__`` kwargs they need; the
|
|
116
|
+
workload-type instantiates the class once and reuses the same instance
|
|
117
|
+
across tickets, so any expensive setup (loading tools, opening sessions)
|
|
118
|
+
happens once. Override ``aclose`` to release resources.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
async def run(self, ctx: AgentContext) -> AgentResult:
|
|
123
|
+
"""Handle one task; return its final result."""
|
|
124
|
+
|
|
125
|
+
async def aclose(self) -> None:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CallableAgent(Agent):
|
|
130
|
+
"""Adapter wrapping a sync/async ``(AgentContext) -> AgentResult|dict|str``."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, fn: Callable[[AgentContext], Any], name: str = "callable"):
|
|
133
|
+
self._fn = fn
|
|
134
|
+
self._name = name
|
|
135
|
+
|
|
136
|
+
async def run(self, ctx: AgentContext) -> AgentResult:
|
|
137
|
+
result = self._fn(ctx)
|
|
138
|
+
result = await maybe_await(result)
|
|
139
|
+
if isinstance(result, AgentResult):
|
|
140
|
+
return result
|
|
141
|
+
if isinstance(result, dict):
|
|
142
|
+
return AgentResult(**result)
|
|
143
|
+
if isinstance(result, str):
|
|
144
|
+
return AgentResult(output=result)
|
|
145
|
+
raise TypeError(
|
|
146
|
+
f"CallableAgent fn must return AgentResult|dict|str, "
|
|
147
|
+
f"got {type(result).__name__}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AgentWorkloadType(WorkloadType):
|
|
152
|
+
"""Run a user-provided ``Agent`` per ticket.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
agent: an ``Agent`` instance, an ``Agent`` subclass (instantiated with
|
|
156
|
+
``agent_kwargs``), or a plain callable wrapped in ``CallableAgent``.
|
|
157
|
+
agent_kwargs: forwarded to the class constructor when ``agent`` is a class.
|
|
158
|
+
reference_key: item key carrying the gold reference. Copied into
|
|
159
|
+
``Sample.meta`` (and the synthetic ``Request.meta``) so the standard
|
|
160
|
+
``correctness_hook`` finds it. Mirrors ``EvalWorkloadType``.
|
|
161
|
+
extra_meta_keys: additional item keys to copy verbatim into
|
|
162
|
+
``Sample.meta`` (e.g. ``task_id``).
|
|
163
|
+
name: workload-type name (default ``"agent"``).
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
streaming = False
|
|
167
|
+
# We split `reference` out of items ourselves, so apply_correctness should
|
|
168
|
+
# NOT wrap us in EvalWorkloadType — install the post-hook directly.
|
|
169
|
+
handles_reference = True
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
agent: Union[Agent, type, Callable[..., Any]],
|
|
174
|
+
*,
|
|
175
|
+
agent_kwargs: Optional[dict[str, Any]] = None,
|
|
176
|
+
reference_key: str = "reference",
|
|
177
|
+
extra_meta_keys: tuple[str, ...] = (),
|
|
178
|
+
name: str = "agent",
|
|
179
|
+
):
|
|
180
|
+
self.name = name
|
|
181
|
+
self._reference_key = reference_key
|
|
182
|
+
self._extra_meta_keys = tuple(extra_meta_keys)
|
|
183
|
+
self._agent = _coerce_agent(agent, agent_kwargs or {})
|
|
184
|
+
|
|
185
|
+
async def make_request(self, item: Any) -> Request:
|
|
186
|
+
return Request(
|
|
187
|
+
method="AGENT",
|
|
188
|
+
url=f"agent://{self.name}",
|
|
189
|
+
meta=self._split_meta(item),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def run_ticket(self, ctx: TicketContext) -> Sample:
|
|
193
|
+
req = await self.make_request(ctx.item)
|
|
194
|
+
agent_ctx = AgentContext(
|
|
195
|
+
item=ctx.item,
|
|
196
|
+
workload_name=self.name,
|
|
197
|
+
fire=ctx.fire,
|
|
198
|
+
start_mono=ctx.start_mono,
|
|
199
|
+
)
|
|
200
|
+
start = ctx.start_mono
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
result = await self._agent.run(agent_ctx)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
latency = time.monotonic() - start
|
|
206
|
+
error = f"{type(e).__name__}: {e}"
|
|
207
|
+
sample = Sample(
|
|
208
|
+
start_ts=start,
|
|
209
|
+
latency_s=latency,
|
|
210
|
+
status=0,
|
|
211
|
+
ok=False,
|
|
212
|
+
request_ok=False,
|
|
213
|
+
error=error,
|
|
214
|
+
workload=self.name,
|
|
215
|
+
meta=dict(req.meta),
|
|
216
|
+
)
|
|
217
|
+
resp = Response(
|
|
218
|
+
status=0, headers={}, body=b"",
|
|
219
|
+
elapsed_s=latency, ok=False, error=error,
|
|
220
|
+
)
|
|
221
|
+
return await self._run_post_hooks(ctx, req, resp, sample)
|
|
222
|
+
|
|
223
|
+
latency = time.monotonic() - start
|
|
224
|
+
body = (result.output or "").encode("utf-8", errors="replace")
|
|
225
|
+
# `resp.ok` gates the correctness post-hook. We want grading to run
|
|
226
|
+
# whenever the agent finished its trajectory (even an unsuccessful one
|
|
227
|
+
# — empty output correctly grades as "wrong"). Only skip grading on
|
|
228
|
+
# actual infra failure (request_ok=False).
|
|
229
|
+
resp = Response(
|
|
230
|
+
status=200 if result.request_ok else 0,
|
|
231
|
+
headers={},
|
|
232
|
+
body=body,
|
|
233
|
+
elapsed_s=latency,
|
|
234
|
+
ok=result.request_ok,
|
|
235
|
+
error=result.error,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
merged_meta = dict(req.meta)
|
|
239
|
+
merged_meta.update(result.meta or {})
|
|
240
|
+
|
|
241
|
+
extra: dict[str, float] = {}
|
|
242
|
+
for k, v in (result.metrics or {}).items():
|
|
243
|
+
if isinstance(v, (int, float)):
|
|
244
|
+
extra[k] = float(v)
|
|
245
|
+
|
|
246
|
+
sample = Sample(
|
|
247
|
+
start_ts=start,
|
|
248
|
+
latency_s=latency,
|
|
249
|
+
# When the agent ran to completion (request_ok=True) we record a
|
|
250
|
+
# synthetic 200 even if `ok` is False — the trajectory was
|
|
251
|
+
# *delivered*, it just didn't submit a successful answer. That keeps
|
|
252
|
+
# the ok/wrong/failed bucketing meaningful.
|
|
253
|
+
status=200 if result.request_ok else 0,
|
|
254
|
+
ok=result.ok,
|
|
255
|
+
request_ok=result.request_ok,
|
|
256
|
+
bytes_sent=result.bytes_sent,
|
|
257
|
+
bytes_recv=result.bytes_recv or len(body),
|
|
258
|
+
error=result.error,
|
|
259
|
+
workload=self.name,
|
|
260
|
+
meta=merged_meta,
|
|
261
|
+
extra=extra,
|
|
262
|
+
)
|
|
263
|
+
return await self._run_post_hooks(ctx, req, resp, sample)
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
async def _run_post_hooks(ctx: TicketContext, req: Request, resp: Response,
|
|
267
|
+
sample: Sample) -> Sample:
|
|
268
|
+
for hook in ctx.post_hooks:
|
|
269
|
+
sample = await maybe_await(hook(req, resp, sample))
|
|
270
|
+
return sample
|
|
271
|
+
|
|
272
|
+
def _split_meta(self, item: Any) -> dict[str, Any]:
|
|
273
|
+
if not isinstance(item, dict):
|
|
274
|
+
return {}
|
|
275
|
+
keys = {self._reference_key, *self._extra_meta_keys}
|
|
276
|
+
return {k: item[k] for k in keys if k in item}
|
|
277
|
+
|
|
278
|
+
async def aclose(self) -> None:
|
|
279
|
+
await self._agent.aclose()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _coerce_agent(spec: Any, agent_kwargs: dict[str, Any]) -> Agent:
|
|
283
|
+
if isinstance(spec, Agent):
|
|
284
|
+
if agent_kwargs:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"agent_kwargs is only meaningful when 'agent' is a class or "
|
|
287
|
+
"factory — got an Agent instance"
|
|
288
|
+
)
|
|
289
|
+
return spec
|
|
290
|
+
if inspect.isclass(spec) and issubclass(spec, Agent):
|
|
291
|
+
return spec(**agent_kwargs)
|
|
292
|
+
if callable(spec):
|
|
293
|
+
# Factory path: try calling it with agent_kwargs; if the result is an
|
|
294
|
+
# Agent, use it. Otherwise treat the original callable as a per-task fn.
|
|
295
|
+
if agent_kwargs:
|
|
296
|
+
obj = spec(**agent_kwargs)
|
|
297
|
+
if isinstance(obj, Agent):
|
|
298
|
+
return obj
|
|
299
|
+
if callable(obj):
|
|
300
|
+
return CallableAgent(obj)
|
|
301
|
+
raise TypeError(
|
|
302
|
+
f"agent factory returned {type(obj).__name__}; "
|
|
303
|
+
"expected Agent or callable"
|
|
304
|
+
)
|
|
305
|
+
return CallableAgent(spec)
|
|
306
|
+
raise TypeError(
|
|
307
|
+
f"agent must be Agent|Agent-subclass|callable, got {type(spec).__name__}"
|
|
308
|
+
)
|