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