benchkit-cli 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.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/pi_agent.py ADDED
@@ -0,0 +1,476 @@
1
+ """Stock Pi coding-agent RPC adapter running inside a Docker task sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import queue
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from benchkit.client import GenerationUpdate, InferenceClient
18
+ from benchkit.evaluation import EvaluationResult, combine_attempts, repair_message
19
+ from benchkit.sandbox import (
20
+ DockerTaskEnvironment,
21
+ LatestPiImage,
22
+ cleanup_owned_resources,
23
+ )
24
+
25
+
26
+ def _message_blocks(message: object, kind: str, field: str) -> str:
27
+ if not isinstance(message, dict):
28
+ return ""
29
+ content = message.get("content")
30
+ if not isinstance(content, list):
31
+ return ""
32
+ return "".join(
33
+ str(block.get(field) or "")
34
+ for block in content
35
+ if isinstance(block, dict) and block.get("type") == kind
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class _RpcTrace:
41
+ """Incrementally normalize Pi events into BenchKit's generation shape."""
42
+
43
+ final_response: str = ""
44
+ thinking_parts: list[str] = field(default_factory=list)
45
+ output_tokens: int = 0
46
+ input_tokens: int = 0
47
+ turns: int = 0
48
+ tool_calls_started: int = 0
49
+ tool_trace: list[dict[str, Any]] = field(default_factory=list)
50
+ done_reason: str = "stop"
51
+ generation_time_s: float = 0.0
52
+ _assistant_started_at: float | None = field(default=None, repr=False)
53
+ _active_tools: dict[str, tuple[float, dict[str, Any]]] = field(
54
+ default_factory=dict, repr=False
55
+ )
56
+ _streamed_thinking: list[str] = field(default_factory=list, repr=False)
57
+ _streamed_text: list[str] = field(default_factory=list, repr=False)
58
+
59
+ def handle(self, event: dict[str, Any]) -> tuple[str, str]:
60
+ """Consume one RPC event and return streamed thinking/text deltas."""
61
+ event_type = event.get("type")
62
+ thinking_delta = ""
63
+ text_delta = ""
64
+ if event_type == "message_start":
65
+ message = event.get("message") or {}
66
+ if message.get("role") == "assistant":
67
+ self._assistant_started_at = time.monotonic()
68
+ self._streamed_thinking.clear()
69
+ self._streamed_text.clear()
70
+ elif event_type == "message_update":
71
+ delta = event.get("assistantMessageEvent") or {}
72
+ if delta.get("type") == "thinking_delta":
73
+ thinking_delta = str(delta.get("delta") or "")
74
+ self._streamed_thinking.append(thinking_delta)
75
+ elif delta.get("type") == "text_delta":
76
+ text_delta = str(delta.get("delta") or "")
77
+ self._streamed_text.append(text_delta)
78
+ if (thinking_delta or text_delta) and self._assistant_started_at is None:
79
+ self._assistant_started_at = time.monotonic()
80
+ elif event_type == "message_end":
81
+ message = event.get("message") or {}
82
+ if message.get("role") == "assistant":
83
+ if self._assistant_started_at is not None:
84
+ self.generation_time_s += max(
85
+ 0.0, time.monotonic() - self._assistant_started_at
86
+ )
87
+ self._assistant_started_at = None
88
+ self.turns += 1
89
+ text = _message_blocks(message, "text", "text") or "".join(
90
+ self._streamed_text
91
+ )
92
+ if text:
93
+ self.final_response = text
94
+ thinking = _message_blocks(message, "thinking", "thinking") or "".join(
95
+ self._streamed_thinking
96
+ )
97
+ if thinking:
98
+ self.thinking_parts.append(thinking)
99
+ usage = message.get("usage") or {}
100
+ self.input_tokens += sum(
101
+ int(usage.get(key) or 0)
102
+ for key in ("input", "cacheRead", "cacheWrite")
103
+ )
104
+ self.output_tokens += int(usage.get("output") or 0)
105
+ self.done_reason = str(message.get("stopReason") or self.done_reason)
106
+ self._streamed_thinking.clear()
107
+ self._streamed_text.clear()
108
+ elif event_type == "tool_execution_start":
109
+ self.tool_calls_started += 1
110
+ tool_id = str(event.get("toolCallId") or "")
111
+ item = {
112
+ "name": str(event.get("toolName") or ""),
113
+ "arguments": event.get("args") or {},
114
+ }
115
+ self._active_tools[tool_id] = (time.monotonic(), item)
116
+ elif event_type == "tool_execution_end":
117
+ tool_id = str(event.get("toolCallId") or "")
118
+ started, item = self._active_tools.pop(
119
+ tool_id,
120
+ (time.monotonic(), {"name": str(event.get("toolName") or "")}),
121
+ )
122
+ item.update(
123
+ is_error=bool(event.get("isError")),
124
+ duration_s=round(time.monotonic() - started, 3),
125
+ )
126
+ output = _message_blocks(event.get("result"), "text", "text")
127
+ if output:
128
+ item["output_truncated"] = len(output) > 8000
129
+ item["output"] = output[-8000:]
130
+ self.tool_trace.append(item)
131
+ return thinking_delta, text_delta
132
+
133
+
134
+ @dataclass
135
+ class PiAgentRunner:
136
+ """Run the original latest Pi agent, without replacing any native tools."""
137
+
138
+ client: InferenceClient
139
+ image: LatestPiImage = field(default_factory=LatestPiImage)
140
+ owner_id: str = field(default_factory=lambda: uuid.uuid4().hex)
141
+
142
+ @property
143
+ def version(self) -> str:
144
+ return self.image.version
145
+
146
+ def prepare(self) -> str:
147
+ return self.image.prepare()
148
+
149
+ def cleanup(self) -> None:
150
+ try:
151
+ cleanup_owned_resources(self.image.docker, self.owner_id)
152
+ finally:
153
+ self.image.cleanup()
154
+
155
+ def generate(
156
+ self,
157
+ model: str,
158
+ prompt: str,
159
+ on_progress=None,
160
+ cancel_event: threading.Event | None = None,
161
+ verifier: Callable[[str], EvaluationResult] | None = None,
162
+ repair_attempts: int = 0,
163
+ workspace_setup: Callable[[DockerTaskEnvironment], None] | None = None,
164
+ workspace_verifier: Callable[
165
+ [DockerTaskEnvironment, list[dict[str, Any]]], EvaluationResult
166
+ ]
167
+ | None = None,
168
+ repair_prompt_builder: Callable[[str, int, int], str] | None = None,
169
+ ) -> dict:
170
+ self.prepare()
171
+ started = time.perf_counter()
172
+ try:
173
+ timeout = max(0.1, float(os.environ.get("BENCHKIT_PI_TIMEOUT", "900")))
174
+ except ValueError:
175
+ timeout = 900.0
176
+ stderr_parts: list[str] = []
177
+ events: queue.Queue[dict[str, Any] | None] = queue.Queue()
178
+ process: subprocess.Popen[str] | None = None
179
+ timed_out = False
180
+ cancelled = False
181
+ generation_results: list[dict] = []
182
+ attempts: list[tuple[dict, EvaluationResult]] = []
183
+ feedback_sent: list[str] = []
184
+ previous_stats = (0, 0, 0)
185
+ verification_time = 0.0
186
+
187
+ try:
188
+ with DockerTaskEnvironment(
189
+ self.client,
190
+ model,
191
+ self.image,
192
+ owner_id=self.owner_id,
193
+ ) as environment:
194
+ if workspace_setup is not None:
195
+ workspace_setup(environment)
196
+ process = environment.start_pi()
197
+ assert process.stdin is not None
198
+ assert process.stdout is not None
199
+ assert process.stderr is not None
200
+
201
+ def read_stdout() -> None:
202
+ for line in process.stdout:
203
+ try:
204
+ events.put(json.loads(line))
205
+ except json.JSONDecodeError:
206
+ continue
207
+ events.put(None)
208
+
209
+ def read_stderr() -> None:
210
+ for line in process.stderr:
211
+ stderr_parts.append(line)
212
+ if sum(map(len, stderr_parts)) > 16000:
213
+ del stderr_parts[: len(stderr_parts) // 2]
214
+
215
+ threading.Thread(target=read_stdout, daemon=True).start()
216
+ threading.Thread(target=read_stderr, daemon=True).start()
217
+
218
+ send_lock = threading.Lock()
219
+
220
+ def send(payload: dict[str, Any]) -> None:
221
+ with send_lock:
222
+ if process is None or process.stdin is None:
223
+ return
224
+ try:
225
+ process.stdin.write(json.dumps(payload) + "\n")
226
+ process.stdin.flush()
227
+ except OSError:
228
+ return
229
+
230
+ message = prompt
231
+ for attempt in range(max(0, repair_attempts) + 1):
232
+ trace = _RpcTrace()
233
+ turn_started = time.perf_counter()
234
+ prompt_id = f"benchkit-prompt-{attempt}"
235
+ stats_id = f"benchkit-stats-{attempt}"
236
+ final_id = f"benchkit-final-{attempt}"
237
+ send({"id": prompt_id, "type": "prompt", "message": message})
238
+ requested_summary = False
239
+ summary_responses: set[str] = set()
240
+ stats: dict[str, Any] = {}
241
+ last_text: str | None = None
242
+
243
+ while True:
244
+ elapsed = time.perf_counter() - started
245
+ if cancel_event is not None and cancel_event.is_set():
246
+ cancelled = True
247
+ send({"type": "abort"})
248
+ break
249
+ if elapsed >= timeout:
250
+ timed_out = True
251
+ send({"type": "abort"})
252
+ break
253
+ try:
254
+ event = events.get(timeout=0.1)
255
+ except queue.Empty:
256
+ continue
257
+ if event is None:
258
+ break
259
+
260
+ thinking_delta, text_delta = trace.handle(event)
261
+ if on_progress is not None and (thinking_delta or text_delta):
262
+ on_progress(
263
+ GenerationUpdate(
264
+ thinking=thinking_delta,
265
+ response=text_delta,
266
+ elapsed_s=elapsed,
267
+ reasoning_channel_seen=bool(
268
+ thinking_delta or trace.thinking_parts
269
+ ),
270
+ attempt=attempt,
271
+ )
272
+ )
273
+
274
+ if (
275
+ event.get("type") == "agent_settled"
276
+ and not requested_summary
277
+ ):
278
+ requested_summary = True
279
+ send({"id": stats_id, "type": "get_session_stats"})
280
+ send(
281
+ {
282
+ "id": final_id,
283
+ "type": "get_last_assistant_text",
284
+ }
285
+ )
286
+ continue
287
+ if event.get("type") == "response":
288
+ event_id = event.get("id")
289
+ if event_id == prompt_id and not event.get("success"):
290
+ detail = event.get("error") or "Pi rejected the prompt"
291
+ raise RuntimeError(str(detail))
292
+ data = event.get("data") or {}
293
+ if event_id == stats_id:
294
+ stats = data
295
+ summary_responses.add("stats")
296
+ elif event_id == final_id:
297
+ value = data.get("text")
298
+ last_text = str(value) if value is not None else None
299
+ summary_responses.add("final")
300
+ if requested_summary and summary_responses == {
301
+ "stats",
302
+ "final",
303
+ }:
304
+ break
305
+
306
+ if last_text:
307
+ trace.final_response = last_text
308
+ tokens = stats.get("tokens") or {}
309
+ if tokens:
310
+ current_stats = (
311
+ sum(
312
+ int(tokens.get(key) or 0)
313
+ for key in ("input", "cacheRead", "cacheWrite")
314
+ ),
315
+ int(tokens.get("output") or 0),
316
+ int(stats.get("assistantMessages") or 0),
317
+ )
318
+ trace.input_tokens = max(
319
+ trace.input_tokens,
320
+ current_stats[0] - previous_stats[0],
321
+ )
322
+ trace.output_tokens = max(
323
+ trace.output_tokens,
324
+ current_stats[1] - previous_stats[1],
325
+ )
326
+ trace.turns = max(
327
+ trace.turns,
328
+ current_stats[2] - previous_stats[2],
329
+ )
330
+ previous_stats = current_stats
331
+
332
+ turn_elapsed = time.perf_counter() - turn_started
333
+ if on_progress is not None and not cancelled:
334
+ on_progress(
335
+ GenerationUpdate(
336
+ elapsed_s=time.perf_counter() - started,
337
+ reasoning_channel_seen=bool(trace.thinking_parts),
338
+ done=True,
339
+ attempt=attempt,
340
+ )
341
+ )
342
+ thinking = "\n\n".join(trace.thinking_parts)
343
+ error = "".join(stderr_parts).strip()
344
+ length_exceeded = trace.done_reason == "length"
345
+ if (
346
+ not trace.final_response
347
+ and not (cancelled or timed_out or length_exceeded)
348
+ and trace.turns == 0
349
+ ):
350
+ detail = (
351
+ error[-2000:]
352
+ if error
353
+ else "no assistant message was returned"
354
+ )
355
+ raise RuntimeError(
356
+ f"Pi agent exited without a response: {detail}"
357
+ )
358
+ output_tokens = trace.output_tokens
359
+ generation_time_s = trace.generation_time_s
360
+ generation = {
361
+ "thinking": thinking,
362
+ "response": trace.final_response,
363
+ "trace_status": (
364
+ "observed"
365
+ if thinking
366
+ else "available_empty"
367
+ if trace.turns
368
+ else "unavailable"
369
+ ),
370
+ "tok_s": (
371
+ output_tokens / generation_time_s
372
+ if generation_time_s > 0
373
+ else 0.0
374
+ ),
375
+ "eval_count": output_tokens,
376
+ "eval_duration_ns": int(generation_time_s * 1e9),
377
+ "response_time_s": turn_elapsed,
378
+ "done_reason": (
379
+ "cancelled"
380
+ if cancelled
381
+ else "timeout"
382
+ if timed_out
383
+ else trace.done_reason
384
+ ),
385
+ "timed_out": timed_out,
386
+ "timeout_s": timeout,
387
+ "cancelled": cancelled,
388
+ "length_exceeded": length_exceeded,
389
+ "harness": "pi",
390
+ "harness_version": self.version,
391
+ "input_tokens": trace.input_tokens,
392
+ "model_turns": trace.turns,
393
+ "tool_calls": trace.tool_calls_started,
394
+ "tool_trace": trace.tool_trace,
395
+ "workspace_evaluated": workspace_verifier is not None,
396
+ }
397
+ generation_results.append(generation)
398
+
399
+ active_verifier = (
400
+ (
401
+ lambda _response, current_trace=trace: workspace_verifier(
402
+ environment, current_trace.tool_trace
403
+ )
404
+ )
405
+ if workspace_verifier is not None
406
+ else verifier
407
+ )
408
+ if active_verifier is None:
409
+ break
410
+ terminal = cancelled or timed_out or length_exceeded
411
+ verification_started = time.perf_counter()
412
+ evaluation = (
413
+ EvaluationResult(score=0.0)
414
+ if cancelled or (terminal and workspace_verifier is None)
415
+ else active_verifier(trace.final_response)
416
+ )
417
+ verification_time += time.perf_counter() - verification_started
418
+ attempts.append((generation, evaluation))
419
+ if (
420
+ terminal
421
+ or evaluation.passed
422
+ or evaluation.error
423
+ or evaluation.details.get("contamination_verdict")
424
+ == "CONTAMINATED"
425
+ ):
426
+ break
427
+ if attempt >= repair_attempts:
428
+ break
429
+ feedback_sent.append(evaluation.feedback)
430
+ builder = repair_prompt_builder or repair_message
431
+ message = builder(evaluation.feedback, attempt + 1, repair_attempts)
432
+ scaffold_value = environment.pi_scaffold()
433
+ scaffold = (
434
+ dict(scaffold_value) if isinstance(scaffold_value, dict) else {}
435
+ )
436
+ system_prompt = str(scaffold.get("system_prompt") or "")
437
+ tokenize = getattr(self.client, "tokenize", None)
438
+ if system_prompt and callable(tokenize):
439
+ try:
440
+ tokens = tokenize(model, system_prompt, add_special=False)
441
+ except (TypeError, ValueError):
442
+ tokens = None
443
+ if tokens is not None:
444
+ scaffold["system_prompt_tokens"] = len(tokens)
445
+ for generation in generation_results:
446
+ generation["pi_scaffold"] = scaffold
447
+ finally:
448
+ if process is not None and process.poll() is None:
449
+ process.terminate()
450
+ try:
451
+ process.wait(timeout=3)
452
+ except subprocess.TimeoutExpired:
453
+ process.kill()
454
+ with contextlib.suppress(subprocess.TimeoutExpired):
455
+ process.wait(timeout=3)
456
+
457
+ if not generation_results:
458
+ raise RuntimeError("Pi agent exited before producing a generation")
459
+
460
+ elapsed = time.perf_counter() - started
461
+ accounted = sum(
462
+ float(generation.get("response_time_s") or 0.0)
463
+ for generation in generation_results
464
+ )
465
+ overhead = max(0.0, elapsed - accounted - verification_time)
466
+ last_generation = generation_results[-1]
467
+ last_generation["response_time_s"] += overhead
468
+
469
+ if verifier is None and workspace_verifier is None:
470
+ output_tokens = int(last_generation.get("eval_count") or 0)
471
+ generation_ns = int(last_generation.get("eval_duration_ns") or 0)
472
+ last_generation["tok_s"] = (
473
+ output_tokens / (generation_ns / 1e9) if generation_ns > 0 else 0.0
474
+ )
475
+ return last_generation
476
+ return combine_attempts(attempts, feedback_sent)