verifiers 0.3.2.dev171__py3-none-any.whl → 0.3.2.dev185__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.
verifiers/v1/__init__.py CHANGED
@@ -66,6 +66,8 @@ from verifiers.v1.harness import Harness, HarnessSession
66
66
  from verifiers.v1.judge import Judge, JudgeResponse, JudgeView
67
67
  from verifiers.v1.judges import (
68
68
  Criterion,
69
+ DecisionJudge,
70
+ DecisionJudgeConfig,
69
71
  ReferenceJudge,
70
72
  ReferenceJudgeConfig,
71
73
  RubricJudge,
@@ -358,6 +360,8 @@ __all__ = [ # noqa: RUF022 - grouped by public API area
358
360
  "Judges",
359
361
  "JudgeResponse",
360
362
  "JudgeView",
363
+ "DecisionJudge",
364
+ "DecisionJudgeConfig",
361
365
  "ReferenceJudge",
362
366
  "ReferenceJudgeConfig",
363
367
  "RubricJudge",
@@ -231,6 +231,10 @@ class ACPHarnessSession(HarnessSession):
231
231
  self._stderr_tail = bytearray()
232
232
  self._stderr_task: asyncio.Task[None] | None = None
233
233
  self._lock = asyncio.Lock()
234
+ self._write_lock = asyncio.Lock()
235
+ self._responses: dict[int, asyncio.Future] = {}
236
+ self._request_id = 0
237
+ self._response_task: asyncio.Task | None = None
234
238
 
235
239
  async def _start(self) -> None:
236
240
  self._stderr_tail.clear()
@@ -242,8 +246,57 @@ class ACPHarnessSession(HarnessSession):
242
246
  process = await self.runtime.open_process(program, self.config.env)
243
247
  self._process = process
244
248
  self._reader = _PacketReader(process.stdout)
249
+ self._response_task = asyncio.create_task(self._read_responses(self._reader))
245
250
  self._stderr_task = asyncio.create_task(self._drain_stderr(process.stderr))
246
251
 
252
+ async def _read_responses(self, reader: _PacketReader) -> None:
253
+ try:
254
+ while True:
255
+ response = await reader.read()
256
+ future = self._responses.get(response.get("id"))
257
+ if future is not None and not future.done():
258
+ future.set_result(response)
259
+ except BaseException as error: # noqa: BLE001 - settle callers on disconnect or shutdown
260
+ for future in self._responses.values():
261
+ if not future.done():
262
+ future.set_exception(
263
+ RuntimeError(f"ACP connection closed: {error}")
264
+ )
265
+
266
+ async def _request(self, operation: str, **payload: Any) -> JsonObject:
267
+ process = self._process
268
+ if process is None or self._response_task is None or self._response_task.done():
269
+ raise RuntimeError("ACP process is not running")
270
+ self._request_id += 1
271
+ request_id = self._request_id
272
+ future = asyncio.get_running_loop().create_future()
273
+ self._responses[request_id] = future
274
+ try:
275
+ async with self._write_lock:
276
+ await process.write(
277
+ _packet({"id": request_id, "operation": operation, **payload})
278
+ )
279
+ return await future
280
+ finally:
281
+ self._responses.pop(request_id, None)
282
+ if not future.done():
283
+ future.cancel()
284
+
285
+ async def steer(self, message: str, *, message_id: str | None = None) -> dict:
286
+ if self._closed:
287
+ raise RuntimeError("ACP session is closed")
288
+ if not isinstance(message, str) or not message.strip():
289
+ raise ValueError("steering requires a nonempty user message")
290
+ if self._process is None:
291
+ return {"outcome": "promptRequired", "reason": "noRunningTurn"}
292
+ response = await self._request("steer", message=message, message_id=message_id)
293
+ if not response.get("ok"):
294
+ raise RuntimeError(response.get("error") or "ACP steering failed")
295
+ result = response.get("result")
296
+ if not isinstance(result, dict):
297
+ raise TypeError("invalid ACP steering receipt")
298
+ return result
299
+
247
300
  async def _drain_stderr(self, stream: AsyncIterator[bytes]) -> None:
248
301
  async for chunk in stream:
249
302
  self._stderr_tail.extend(chunk)
@@ -295,10 +348,7 @@ class ACPHarnessSession(HarnessSession):
295
348
  assert self._reader is not None
296
349
  calls_before = len(self.trace.calls)
297
350
  try:
298
- await self._process.write(
299
- _packet({"operation": "prompt", "config": config})
300
- )
301
- response = await self._reader.read()
351
+ response = await self._request("prompt", config=config)
302
352
  except BaseException:
303
353
  await run_shielded(self._stop(graceful=False))
304
354
  raise
@@ -323,17 +373,18 @@ class ACPHarnessSession(HarnessSession):
323
373
  return result
324
374
 
325
375
  async def _stop(self, *, graceful: bool) -> dict[str, Any]:
326
- process, self._process = self._process, None
327
- reader, self._reader = self._reader, None
376
+ process = self._process
377
+ response_task = self._response_task
328
378
  stderr_task, self._stderr_task = self._stderr_task, None
329
379
  if process is None:
330
380
  return {}
331
381
  response_metadata: dict[str, Any] = {}
332
382
  try:
333
- if graceful and reader is not None:
383
+ if graceful:
334
384
  with contextlib.suppress(BaseException):
335
- await process.write(_packet({"operation": "shutdown"}))
336
- response = await asyncio.wait_for(reader.read(), timeout=10)
385
+ response = await asyncio.wait_for(
386
+ self._request("shutdown"), timeout=10
387
+ )
337
388
  result = response.get("result")
338
389
  if response.get("ok") and isinstance(result, dict):
339
390
  metadata = result.get("response_metadata")
@@ -353,6 +404,13 @@ class ACPHarnessSession(HarnessSession):
353
404
  except TimeoutError:
354
405
  continue
355
406
  finally:
407
+ self._process = None
408
+ self._reader = None
409
+ self._response_task = None
410
+ if response_task is not None:
411
+ response_task.cancel()
412
+ with contextlib.suppress(BaseException):
413
+ await response_task
356
414
  if stderr_task is not None:
357
415
  if not stderr_task.done():
358
416
  stderr_task.cancel()
@@ -240,6 +240,7 @@ class ACPSession:
240
240
  self.stack = AsyncExitStack()
241
241
  self.connection: Any = None
242
242
  self.capabilities: Any = None
243
+ self.supports_steering = False
243
244
  self.session_id: str | None = None
244
245
  self.is_new = True
245
246
 
@@ -263,6 +264,9 @@ class ACPSession:
263
264
  ),
264
265
  )
265
266
  self.capabilities = initialized.agent_capabilities
267
+ self.supports_steering = (initialized.field_meta or {}).get(
268
+ "steering", {}
269
+ ).get("supported") is True
266
270
  session = await self.connection.new_session(
267
271
  cwd=os.getcwd(),
268
272
  mcp_servers=mcp_servers(config),
@@ -294,6 +298,20 @@ class ACPSession:
294
298
  self.is_new = False
295
299
  return result
296
300
 
301
+ async def steer(self, message: str, message_id: str | None = None) -> dict:
302
+ if self.connection is None or self.session_id is None:
303
+ return {"outcome": "promptRequired", "reason": "noRunningTurn"}
304
+ if not self.supports_steering:
305
+ raise NotImplementedError("ACP agent does not advertise steering support")
306
+ params = {
307
+ "sessionId": self.session_id,
308
+ "prompt": [{"type": "text", "text": message}],
309
+ "_meta": {"steering": {"idleBehavior": "promptRequired"}},
310
+ }
311
+ if message_id is not None:
312
+ params["messageId"] = message_id
313
+ return await self.connection.ext_method("session/steering", params)
314
+
297
315
  async def close(self) -> dict[str, Any]:
298
316
  response_metadata: dict[str, Any] = {}
299
317
  try:
@@ -350,39 +368,50 @@ async def serve_stream() -> None:
350
368
  await asyncio.get_running_loop().connect_read_pipe(
351
369
  lambda: protocol, sys.stdin.buffer
352
370
  )
371
+ tasks: set[asyncio.Task] = set()
372
+ prompt_lock = asyncio.Lock()
373
+
374
+ async def handle(request: dict) -> None:
375
+ operation = request.get("operation")
376
+ try:
377
+ if operation == "prompt":
378
+ async with prompt_lock:
379
+ result = asdict(await session.run(request["config"]))
380
+ elif operation == "steer":
381
+ result = await session.steer(
382
+ request["message"], request.get("message_id")
383
+ )
384
+ elif operation == "shutdown":
385
+ result = {"response_metadata": await session.close()}
386
+ else:
387
+ raise ValueError(f"unknown ACP session operation: {operation!r}")
388
+ response = {"ok": True, "result": result}
389
+ except Exception as error: # noqa: BLE001 - serialize protocol failures
390
+ traceback.print_exc()
391
+ response = {"ok": False, "error": f"{type(error).__name__}: {error}"}
392
+ if isinstance(error, RequestError) and isinstance(error.data, dict):
393
+ response["error"] = error.data.get("details") or str(error)
394
+ response["error_data"] = error.data
395
+ if operation == "prompt":
396
+ response["result"] = asdict(session.client.turn_result())
397
+ response["id"] = request.get("id")
398
+ write_packet(sys.stdout.buffer, response)
399
+
353
400
  try:
354
401
  while request := await read_packet(reader):
355
- stop = False
356
- try:
357
- operation = request.get("operation")
358
- if operation == "prompt":
359
- response = {
360
- "ok": True,
361
- "result": asdict(await session.run(request["config"])),
362
- }
363
- elif operation == "shutdown":
364
- stop = True
365
- response = {
366
- "ok": True,
367
- "result": {"response_metadata": await session.close()},
368
- }
369
- else:
370
- raise ValueError(f"unknown ACP session operation: {operation!r}")
371
- except Exception as error: # noqa: BLE001 - serialize protocol failures
372
- traceback.print_exc()
373
- response = {
374
- "ok": False,
375
- "error": f"{type(error).__name__}: {error}",
376
- }
377
- if isinstance(error, RequestError) and isinstance(error.data, dict):
378
- response["error"] = error.data.get("details") or str(error)
379
- response["error_data"] = error.data
380
- if operation == "prompt":
381
- response["result"] = asdict(session.client.turn_result())
382
- write_packet(sys.stdout.buffer, response)
383
- if stop:
402
+ if request.get("operation") == "shutdown":
403
+ if tasks:
404
+ await asyncio.gather(*tasks)
405
+ await handle(request)
384
406
  break
407
+ task = asyncio.create_task(handle(request))
408
+ tasks.add(task)
409
+ task.add_done_callback(tasks.discard)
385
410
  finally:
411
+ for task in tasks:
412
+ task.cancel()
413
+ if tasks:
414
+ await asyncio.gather(*tasks, return_exceptions=True)
386
415
  await session.close()
387
416
 
388
417
 
verifiers/v1/agent.py CHANGED
@@ -11,8 +11,9 @@ import asyncio
11
11
  import logging
12
12
  from collections.abc import AsyncIterator, Callable, Mapping
13
13
  from contextlib import asynccontextmanager, nullcontext
14
- from dataclasses import dataclass, replace
15
- from typing import Generic, Self, cast
14
+ from dataclasses import dataclass, field, replace
15
+ from typing import Generic, Literal, Self, cast
16
+ from weakref import WeakValueDictionary
16
17
 
17
18
  from typing_extensions import TypeVar
18
19
 
@@ -162,6 +163,15 @@ class Segment:
162
163
  return ""
163
164
 
164
165
 
166
+ @dataclass
167
+ class _PendingMessage:
168
+ text: str
169
+ message_id: str | None
170
+ receipt: dict | None = None
171
+ error: BaseException | None = None
172
+ done: asyncio.Event = field(default_factory=asyncio.Event)
173
+
174
+
165
175
  class Interaction:
166
176
  """An agent's rollout, held open turn-by-turn: the caller IS the run's user.
167
177
 
@@ -170,8 +180,8 @@ class Interaction:
170
180
  program, resumed onto the conversation, until it yields — returning the
171
181
  resulting `Segment`. A prompt-less (or masked) task is opened by the first
172
182
  `turn(message)`; a prompted task speaks first — a bare `turn()` takes its
173
- opening reply. One consumer at a time — `turn()` is a strict
174
- request/response alternation, not a mailbox. `interaction.trace` is live from
183
+ opening reply. Turns are serialized; `steer(..., wake=True)` can run a
184
+ background turn when idle. `interaction.trace` is live from
175
185
  the moment the interaction exists: watch tokens and turns mid-exchange, read
176
186
  rewards after close. Leaving the `interaction()` context closes the exchange
177
187
  as `user_closed` and finishes the rollout — hooks and scoring included."""
@@ -182,11 +192,263 @@ class Interaction:
182
192
  self._over = False # a terminated segment was already delivered
183
193
  self._started = False # a segment has run (the exchange is under way)
184
194
  self._lock = asyncio.Lock()
195
+ self._steering_locks: WeakValueDictionary[str, asyncio.Lock] = (
196
+ WeakValueDictionary()
197
+ )
198
+ self._steering_receipts: dict[str, tuple[str, dict]] = {}
199
+ self._steering_tasks: dict[object, tuple[str, asyncio.Task[dict]]] = {}
200
+ self._pending_messages: list[_PendingMessage] = []
201
+ self._pending_by_id: dict[str, _PendingMessage] = {}
202
+ self._message_drain: asyncio.Task[None] | None = None
203
+ self._closing = False
185
204
 
186
205
  @property
187
206
  def trace(self) -> Trace:
188
207
  return self._run.trace
189
208
 
209
+ async def send(
210
+ self,
211
+ message: str,
212
+ *,
213
+ mode: Literal["steer", "queue"] = "steer",
214
+ message_id: str | None = None,
215
+ ) -> dict:
216
+ """Deliver a user message to this open interaction.
217
+
218
+ ``steer`` delivers at the harness's next safe point during active work.
219
+ ``queue`` waits for the current turn to yield, then delivers all pending
220
+ messages together in arrival order in one new turn. It does not wake a
221
+ wait tool inside the active turn. Messages arriving after the batch is
222
+ drained wait for the following turn.
223
+ Both resume an idle session. Queue mode also works without ACP steering.
224
+ Unsupported steering raises; it never silently changes to queue mode.
225
+ Sends return on acceptance, not turn completion. For queued sends and
226
+ idle wake-ups, the first model request acknowledges the resumed input.
227
+ Cancellation withdraws a queued message that has not started. After a batch starts, cancelling
228
+ a sender does not cancel the shared turn. Callers must await or schedule
229
+ this coroutine to submit input; closing the interaction cancels any
230
+ outstanding delivery tasks.
231
+ The receipt confirms delivery, not that the interaction is finished;
232
+ keep its context open
233
+ while more messages may arrive. A prompted task must start its opening
234
+ turn first. Stable IDs deduplicate successful deliveries.
235
+ """
236
+ if mode not in ("steer", "queue"):
237
+ raise ValueError("message mode must be 'steer' or 'queue'")
238
+ return await self._send(message, message_id=message_id, mode=mode, wake=True)
239
+
240
+ async def steer(
241
+ self, message: str, *, message_id: str | None = None, wake: bool = False
242
+ ) -> dict:
243
+ """Inject a user message, optionally resuming an idle interaction.
244
+
245
+ With ``wake=True``, an idle session runs another turn on this message.
246
+ The interaction must remain open, and a prompted task must have started
247
+ its opening turn. Wake-up uses the normal turn budget and lifecycle.
248
+ Successful deliveries with a stable ID are deduplicated in this interaction.
249
+ """
250
+ return await self._send(message, message_id=message_id, mode="steer", wake=wake)
251
+
252
+ async def _send(
253
+ self,
254
+ message: str,
255
+ *,
256
+ message_id: str | None,
257
+ mode: Literal["steer", "queue"],
258
+ wake: bool,
259
+ ) -> dict:
260
+ if not isinstance(message, str) or not message.strip():
261
+ raise ValueError("delivery requires a nonempty user message")
262
+ # Only retries of the same message share a lock. A wake-up can run a
263
+ # full turn; unrelated messages must still reach that turn through ACP.
264
+ lock = (
265
+ self._steering_locks.setdefault(message_id, asyncio.Lock())
266
+ if message_id is not None
267
+ else nullcontext()
268
+ )
269
+ async with lock:
270
+ if self._closing or self._run.closed:
271
+ raise RuntimeError("this interaction is closed")
272
+ if message_id is not None and message_id in self._steering_receipts:
273
+ previous, receipt = self._steering_receipts[message_id]
274
+ if previous != message:
275
+ raise ValueError(
276
+ "steering message ID reused with different content"
277
+ )
278
+ return dict(receipt)
279
+ if message_id is not None and message_id in self._pending_by_id:
280
+ pending = self._pending_by_id[message_id]
281
+ if pending.text != message:
282
+ raise ValueError(
283
+ "steering message ID reused with different content"
284
+ )
285
+ await pending.done.wait()
286
+ if pending.error is not None:
287
+ raise pending.error
288
+ assert pending.receipt is not None
289
+ return dict(pending.receipt)
290
+ if message_id in self._steering_tasks:
291
+ receipt = await self._deliver_steer(message, message_id)
292
+ elif self._over or not self._run.ok:
293
+ return {"outcome": "promptRequired", "reason": "interactionStopped"}
294
+ elif mode == "steer":
295
+ receipt = await self._deliver_steer(message, message_id)
296
+ else:
297
+ receipt = {"outcome": "promptRequired", "reason": "noRunningTurn"}
298
+ if wake and receipt.get("outcome") == "promptRequired":
299
+ receipt = await self._resume_messages(message, message_id)
300
+ if receipt.get("outcome") == "injected" and message_id is not None:
301
+ self._steering_receipts[message_id] = (message, dict(receipt))
302
+ return receipt
303
+
304
+ async def _deliver_steer(self, message: str, message_id: str | None) -> dict:
305
+ key = message_id if message_id is not None else object()
306
+ if key in self._steering_tasks:
307
+ previous, task = self._steering_tasks[key]
308
+ if previous != message:
309
+ raise ValueError("steering message ID reused with different content")
310
+ else:
311
+
312
+ async def deliver() -> dict:
313
+ try:
314
+ receipt = await self._run.steer(message, message_id=message_id)
315
+ if receipt.get("outcome") == "injected" and message_id is not None:
316
+ self._steering_receipts[message_id] = (message, dict(receipt))
317
+ return receipt
318
+ finally:
319
+ self._steering_tasks.pop(key, None)
320
+
321
+ # The harness may already have accepted input when the caller is
322
+ # cancelled. Finish and cache that delivery before allowing retries.
323
+ task = asyncio.create_task(deliver())
324
+ self._steering_tasks[key] = (message, task)
325
+ task.add_done_callback(
326
+ lambda done: None if done.cancelled() else done.exception()
327
+ )
328
+ return await asyncio.shield(task)
329
+
330
+ async def _cancel_deliveries(self) -> None:
331
+ self._closing = True
332
+ tasks = [task for _, task in self._steering_tasks.values()]
333
+ if self._message_drain is not None:
334
+ tasks.append(self._message_drain)
335
+ for task in tasks:
336
+ task.cancel()
337
+ await asyncio.gather(*tasks, return_exceptions=True)
338
+ self._steering_tasks.clear()
339
+ # A task cancelled before it starts never enters its finally block.
340
+ self._finish_pending(asyncio.CancelledError())
341
+
342
+ def _finish_pending(self, error: BaseException) -> None:
343
+ for item in self._pending_messages:
344
+ item.error = error
345
+ item.done.set()
346
+ if item.message_id is not None:
347
+ self._pending_by_id.pop(item.message_id, None)
348
+ self._pending_messages.clear()
349
+
350
+ async def _resume_messages(self, message: str, message_id: str | None) -> dict:
351
+ if self._closing:
352
+ raise RuntimeError("this interaction is closed")
353
+ pending = _PendingMessage(message, message_id)
354
+ self._pending_messages.append(pending)
355
+ if message_id is not None:
356
+ self._pending_by_id[message_id] = pending
357
+ if self._message_drain is None or self._message_drain.done():
358
+ self._message_drain = asyncio.create_task(self._drain_messages())
359
+ self._message_drain.add_done_callback(
360
+ lambda done: None if done.cancelled() else done.exception()
361
+ )
362
+ try:
363
+ await pending.done.wait()
364
+ if pending.error is not None:
365
+ raise pending.error
366
+ assert pending.receipt is not None
367
+ return dict(pending.receipt)
368
+ finally:
369
+ # Cancellation withdraws only input the worker has not drained.
370
+ if message_id is not None and any(
371
+ item is pending for item in self._pending_messages
372
+ ):
373
+ self._pending_by_id.pop(message_id, None)
374
+ self._pending_messages = [
375
+ item for item in self._pending_messages if item is not pending
376
+ ]
377
+
378
+ async def _drain_messages(self) -> None:
379
+ try:
380
+ while self._pending_messages:
381
+ async with self._lock, self._gate or nullcontext():
382
+ batch, self._pending_messages = self._pending_messages, []
383
+ if not batch:
384
+ continue
385
+ try:
386
+ if self._closing or self._run.closed:
387
+ raise RuntimeError("this interaction is closed")
388
+ if self._over or not self._run.ok:
389
+ receipt = {
390
+ "outcome": "promptRequired",
391
+ "reason": "interactionStopped",
392
+ }
393
+ elif (
394
+ not self._started
395
+ and self.trace.task.data.prompt is not None
396
+ ):
397
+ receipt = {
398
+ "outcome": "promptRequired",
399
+ "reason": "noRunningTurn",
400
+ }
401
+ else:
402
+
403
+ def accepted(batch: list[_PendingMessage] = batch) -> None:
404
+ for item in batch:
405
+ item.receipt = {"outcome": "injected"}
406
+ if item.message_id is not None:
407
+ self._steering_receipts[item.message_id] = (
408
+ item.text,
409
+ dict(item.receipt),
410
+ )
411
+ self._pending_by_id.pop(item.message_id, None)
412
+ item.done.set()
413
+
414
+ segment = await self._turn(
415
+ [UserMessage(content=item.text) for item in batch],
416
+ on_input=accepted,
417
+ )
418
+ receipt = (
419
+ {
420
+ "outcome": "promptRequired",
421
+ "reason": "interactionStopped",
422
+ }
423
+ if segment.terminated
424
+ else {"outcome": "injected"}
425
+ )
426
+ for item in batch:
427
+ if item.done.is_set():
428
+ continue
429
+ item.receipt = receipt
430
+ if (
431
+ receipt.get("outcome") == "injected"
432
+ and item.message_id is not None
433
+ ):
434
+ self._steering_receipts[item.message_id] = (
435
+ item.text,
436
+ dict(receipt),
437
+ )
438
+ except BaseException as error:
439
+ for item in batch:
440
+ if not item.done.is_set():
441
+ item.error = error
442
+ raise
443
+ finally:
444
+ for item in batch:
445
+ if item.message_id is not None:
446
+ self._pending_by_id.pop(item.message_id, None)
447
+ item.done.set()
448
+ except BaseException as error:
449
+ self._finish_pending(error)
450
+ raise
451
+
190
452
  async def turn(self, message: str | Messages | None = None) -> Segment:
191
453
  """Send one user turn (a string, or full `Messages` for multimodal /
192
454
  multi-message turns); run one segment; return its `Segment`. A
@@ -196,8 +458,13 @@ class Interaction:
196
458
  async with self._lock, self._gate or nullcontext():
197
459
  return await self._turn(message)
198
460
 
199
- async def _turn(self, message: str | Messages | None) -> Segment:
200
- if self._run.closed:
461
+ async def _turn(
462
+ self,
463
+ message: str | Messages | None,
464
+ *,
465
+ on_input: Callable[[], None] | None = None,
466
+ ) -> Segment:
467
+ if self._closing or self._run.closed:
201
468
  raise RuntimeError("this interaction is closed")
202
469
  if self._over:
203
470
  raise RuntimeError(
@@ -225,7 +492,7 @@ class Interaction:
225
492
  self._started = True
226
493
  turns_before = self.trace.num_turns
227
494
  nodes_before = len(self.trace.nodes)
228
- await self._run.step(messages)
495
+ await self._run.step(messages, on_input=on_input)
229
496
  if self.trace.num_turns > turns_before:
230
497
  # The segment answered — even if a limit or @stop then ended the
231
498
  # exchange, that surfaces as the NEXT turn's terminated segment.
@@ -247,6 +514,7 @@ class Interaction:
247
514
  async def close(self) -> Trace:
248
515
  """End the exchange and finish the rollout (idempotent): scoring and hooks
249
516
  run, then the finished trace returns (also on `interaction.trace`)."""
517
+ await self._cancel_deliveries()
250
518
  async with self._lock, self._gate or nullcontext():
251
519
  if not self._run.closed and self._run.ok:
252
520
  self.trace.stop("user_closed")
@@ -525,6 +793,7 @@ class Agent:
525
793
  run.fail(e)
526
794
  raise
527
795
  except BaseException:
796
+ await interaction._cancel_deliveries()
528
797
  await run.abort()
529
798
  raise
530
799
  finally:
@@ -60,7 +60,10 @@ class BaseClientConfig(BaseConfig):
60
60
  or prime_config.get("inference_url")
61
61
  or DEFAULT_PRIME_INFERENCE_URL
62
62
  )
63
- if "base_url" not in self.model_fields_set:
63
+ if (
64
+ "base_url" not in self.model_fields_set
65
+ and self.base_url == DEFAULT_PRIME_INFERENCE_URL
66
+ ):
64
67
  self.base_url = prime_base_url
65
68
  host = urlparse(self.base_url).hostname or ""
66
69
  if host != PRIME_INFERENCE_HOST and not host.endswith(
@@ -40,7 +40,7 @@ def resolve_judges(entries: Sequence[Any]) -> list[JudgeConfig]:
40
40
  if not raw.get("id"):
41
41
  raise ValueError(
42
42
  "each `judges` entry needs an `id` (a judge plugin: `reference`, "
43
- "`rubric`, or an installed package)"
43
+ "`rubric`, `decision`, or an installed package)"
44
44
  )
45
45
  resolved.append(judge_config_type(raw["id"]).model_validate(raw))
46
46
  return resolved
@@ -51,7 +51,7 @@ def check_judges(entries: Sequence[JudgeConfig]) -> None:
51
51
  if not entry.id:
52
52
  raise ValueError(
53
53
  "each `judges` entry needs an `id` (a judge plugin: `reference`, "
54
- "`rubric`, or an installed package)"
54
+ "`rubric`, `decision`, or an installed package)"
55
55
  )
56
56
  keys = [judge_key(entry) for entry in entries]
57
57
  if duplicates := {key for key in keys if keys.count(key) > 1}:
verifiers/v1/harness.py CHANGED
@@ -347,6 +347,12 @@ class HarnessSession:
347
347
  self.tool_interception_url = tool_interception_url
348
348
  self._closed = False
349
349
 
350
+ async def steer(self, message: str, *, message_id: str | None = None) -> dict:
351
+ """Deliver a user message during a running turn, if supported by the harness."""
352
+ raise NotImplementedError(
353
+ f"harness {self.harness.config.id!r} does not support steering"
354
+ )
355
+
350
356
  async def turn(self, messages: Messages | None = None) -> None:
351
357
  """Run one harness segment while retaining session state for the next."""
352
358
  if self._closed:
@@ -752,6 +752,9 @@ class InterceptionServer(Interception):
752
752
  # The tail is what the harness added since the last turn (tool results, user
753
753
  # turns): live watchers see it now rather than with the model's reply.
754
754
  session.trace.preview(turn, turn.tail)
755
+ if session.on_turn_input is not None:
756
+ on_input, session.on_turn_input = session.on_turn_input, None
757
+ on_input()
755
758
 
756
759
  def serve(response: Response, events: bytes | None) -> web.Response:
757
760
  if streaming:
@@ -1,8 +1,11 @@
1
+ from verifiers.v1.judges.decision import DecisionJudge, DecisionJudgeConfig
1
2
  from verifiers.v1.judges.reference import ReferenceJudge, ReferenceJudgeConfig
2
3
  from verifiers.v1.judges.rubric import Criterion, RubricJudge, RubricJudgeConfig
3
4
 
4
5
  __all__ = [
5
6
  "Criterion",
7
+ "DecisionJudge",
8
+ "DecisionJudgeConfig",
6
9
  "ReferenceJudge",
7
10
  "ReferenceJudgeConfig",
8
11
  "RubricJudge",
@@ -0,0 +1,105 @@
1
+ """Decision-model judge using Prime's evaluations API by default."""
2
+
3
+ import json
4
+ from typing import Any, cast
5
+
6
+ from pydantic import Field
7
+
8
+ from verifiers.v1.clients.base import build_async_openai
9
+ from verifiers.v1.configs.judge import JudgeConfig
10
+ from verifiers.v1.judge import (
11
+ Judge,
12
+ JudgeResponse,
13
+ JudgeView,
14
+ judge_question,
15
+ judge_response,
16
+ )
17
+ from verifiers.v1.task import TaskData
18
+ from verifiers.v1.trace import Trace
19
+ from verifiers.v1.types import ID, Usage
20
+
21
+
22
+ class DecisionJudgeConfig(JudgeConfig):
23
+ id: ID = "decision"
24
+ model: str = "typesafe-ai/jev"
25
+ base_url: str = "https://api.pinference.ai/api/v1/evaluations"
26
+ """Full URL to POST evaluation requests to."""
27
+ levels: list[str] = Field(
28
+ default_factory=lambda: ["Incorrect", "Correct"], min_length=2, max_length=10
29
+ )
30
+ """Grading levels, worst to best. The model's probability-weighted level is
31
+ normalized to [0, 1], so a two-level grade is the probability of correctness."""
32
+ question_field: str = ""
33
+ """Task field containing the question; empty uses the task's prompt text."""
34
+ answer_field: str = ""
35
+ """Optional task field containing a reference answer to include in the state."""
36
+ view: JudgeView = "last_reply"
37
+
38
+
39
+ class DecisionJudge(Judge[float, DecisionJudgeConfig]):
40
+ prompt = (
41
+ "Grade how well the response satisfies the task in question. "
42
+ "Use the reference answer when provided; equivalent answers are acceptable."
43
+ )
44
+
45
+ async def evaluate(
46
+ self, *, trace: Trace | None = None, **fields: Any
47
+ ) -> JudgeResponse[float]:
48
+ request = {
49
+ "model": self.config.model,
50
+ "state": fields,
51
+ "questions": {
52
+ "grade": {
53
+ "type": "score",
54
+ "instructions": self.build_messages(**fields),
55
+ "criteria": self.config.levels,
56
+ }
57
+ },
58
+ }
59
+ async with build_async_openai(self.config) as client:
60
+ raw = await client.post(
61
+ self.config.base_url, cast_to=dict[str, Any], body=request
62
+ )
63
+ result = raw.get("result", raw) # Workers AI wraps its response in `result`.
64
+ response = JudgeResponse[float](text=json.dumps(raw))
65
+ try:
66
+ if usage := result.get("usage"):
67
+ response.usage = Usage(
68
+ prompt_tokens=usage.get("inputTokens", usage.get("input_tokens")),
69
+ completion_tokens=usage.get(
70
+ "outputTokens", usage.get("output_tokens")
71
+ ),
72
+ cost=usage.get("cost"),
73
+ )
74
+ score = result["answers"]["grade"]["score"] / (len(self.config.levels) - 1)
75
+ if not 0 <= score <= 1:
76
+ raise ValueError(f"decision judge score {score!r} is outside [0, 1]")
77
+ response.parsed = score
78
+ return response
79
+ finally:
80
+ # The call was billed even if the score could not be parsed.
81
+ if trace is not None:
82
+ trace.record_judge_call(
83
+ name=self.reward_name, request=request, response=response
84
+ )
85
+
86
+ async def score(self, task: TaskData, trace: Trace) -> float:
87
+ response = judge_response(trace, self.config.view)
88
+ if not response.strip():
89
+ return 0.0
90
+ fields = {
91
+ "question": judge_question(task, self.config.question_field),
92
+ "response": response,
93
+ }
94
+ if self.config.answer_field:
95
+ answer = getattr(task, self.config.answer_field, None)
96
+ if answer is None:
97
+ raise ValueError(
98
+ f"decision judge found no {self.config.answer_field!r} field on the task"
99
+ )
100
+ fields["answer"] = answer
101
+ result = await self.evaluate(trace=trace, **fields)
102
+ return cast(float, result.parsed)
103
+
104
+
105
+ __all__ = ["DecisionJudge", "DecisionJudgeConfig"]
verifiers/v1/rollout.py CHANGED
@@ -405,7 +405,19 @@ class Rollout:
405
405
  self.trace.notify()
406
406
  return not self._session.stopped
407
407
 
408
- async def step(self, messages: Messages | None = None) -> bool:
408
+ async def steer(self, message: str, *, message_id: str | None = None) -> dict:
409
+ if self._closed or not self.ok:
410
+ raise RuntimeError("this rollout is closed or stopped")
411
+ if self._harness_session is None:
412
+ return {"outcome": "promptRequired", "reason": "noRunningTurn"}
413
+ return await self._harness_session.steer(message, message_id=message_id)
414
+
415
+ async def step(
416
+ self,
417
+ messages: Messages | None = None,
418
+ *,
419
+ on_input: Callable[[], None] | None = None,
420
+ ) -> bool:
409
421
  """Run ONE segment: the harness program to its exit. With `messages`, the
410
422
  segment resumes the exchange with the user's turn(s) (`Harness.resume` —
411
423
  for an exchange the user opens, this is also the first segment, on an
@@ -438,6 +450,7 @@ class Rollout:
438
450
  self.trace.request_rewrites.extend(rewrites)
439
451
  if self._session.stopped:
440
452
  return False
453
+ self._session.on_turn_input = on_input
441
454
  await self._harness_session.turn(messages)
442
455
  except TimeoutError as e:
443
456
  # An expired rollout deadline is the agent breaking its time budget: a
@@ -459,6 +472,7 @@ class Rollout:
459
472
  self.fail(e)
460
473
  return False
461
474
  finally:
475
+ self._session.on_turn_input = None
462
476
  if trace.num_turns == turns_before:
463
477
  trace.root_reply = root_reply_before
464
478
  if self._agent_time_remaining is not None:
verifiers/v1/session.py CHANGED
@@ -148,6 +148,8 @@ class RolloutSession:
148
148
  response_interceptors: list[Callable] = field(default_factory=list)
149
149
  request_stops: list[Callable] = field(default_factory=list)
150
150
  response_stops: list[Callable] = field(default_factory=list)
151
+ on_turn_input: Callable[[], None] | None = None
152
+ """One-shot acknowledgement when a resumed harness reaches model inference."""
151
153
  gates_tools: bool = False
152
154
  """Whether the harness asks `/tool` before executing each call (see
153
155
  `Harness.SUPPORTS_TOOL_INTERCEPTION`). Without that gate a pre-execution rewrite
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifiers
3
- Version: 0.3.2.dev171
3
+ Version: 0.3.2.dev185
4
4
  Summary: Verifiers: Environments for LLM Reinforcement Learning
5
5
  Project-URL: Homepage, https://github.com/primeintellect-ai/verifiers
6
6
  Project-URL: Documentation, https://github.com/primeintellect-ai/verifiers
@@ -1,22 +1,22 @@
1
1
  verifiers/__init__.py,sha256=F5IVMw6mhGaPBMCw7QKk0la_r5mMwydP2hdTTdQucik,352
2
- verifiers/v1/__init__.py,sha256=ybCeLKoVNDU9nwla4BG8HrcZ2oXOuXquRBhaorGevxI,8792
3
- verifiers/v1/agent.py,sha256=746LE0HE4DPyxVcgEkAAs8z9qjsR7CQncfKhT1h4jek,32433
2
+ verifiers/v1/__init__.py,sha256=xvS_VfN7QwTethhEJsZCi7lHicKvQdQKh2nhB2h9Lu8,8884
3
+ verifiers/v1/agent.py,sha256=zBqbHDeqdPghg_QOkcyFplIDY3f34qCOtwcxJnl5N-U,45088
4
4
  verifiers/v1/env.py,sha256=fxXBjVyi0milYViHt6O0vBK3JdM2TYSOoQpXd0P0rFE,18110
5
5
  verifiers/v1/episode.py,sha256=UPtA02aGWA-fY8cJKEuYCMtQVnPN1C0BSN13LggNT8U,5986
6
6
  verifiers/v1/errors.py,sha256=nNabzi55J5aHmAmW9UHZVpI7kcBfy4Xcyt4cQthZbM8,5875
7
7
  verifiers/v1/graph.py,sha256=LW0pJRxCCOXZa2KwB0edHIIqmRrzLFBDNyEeOFVskl8,41502
8
- verifiers/v1/harness.py,sha256=AcsCmNHB1UQ75nD7RUawsb65q63NdkcMykD0E6oRDOA,15957
8
+ verifiers/v1/harness.py,sha256=4hnAh7tjiXfXoEYAzn8XhHy8sJ3PhZ0Tti5VqwxUQDs,16251
9
9
  verifiers/v1/judge.py,sha256=uqn-djkK2_7Q3kOjkDo6ipOvV_HOvuLa9y6AoDjRux8,9618
10
- verifiers/v1/rollout.py,sha256=sx9ilprIu-5zrYH9G4yBWZdqzDdb_ctwLJVItLdTo88,26960
10
+ verifiers/v1/rollout.py,sha256=fmDU1qlodpS1A7f-rlSMixMeGwf69hg9O3bLYxIR-ec,27539
11
11
  verifiers/v1/semantic.py,sha256=tX8FYEceuMYauuB3Bc8jLDMS2eR06JjLjTo66R190Fg,4053
12
- verifiers/v1/session.py,sha256=qVMgKmqkItemR3uYUDAFuDz7huP593kHQbge8FlRba8,28420
12
+ verifiers/v1/session.py,sha256=p3Rm0MU-6fPsJLjQcIMg-NOIV-8_tsqcXNTftJ94brY,28555
13
13
  verifiers/v1/state.py,sha256=EckF2bWp-vV4b1jYJ9sLI5xrfGuI5spIgYYwW926toI,595
14
14
  verifiers/v1/task.py,sha256=YN_P3912NYbK4VPTPHu6kyYCpeyoyhDVxBbhpjg8AA0,13185
15
15
  verifiers/v1/taskset.py,sha256=LE_3ogL7itU2Amk-N20cHkZ7Ev6MznJ0HpC8B1xb4w0,9482
16
16
  verifiers/v1/trace.py,sha256=Npc-TrBh-B3H0OvtfA5X31VAlOhfgLDKGdXohqC2uIk,31156
17
17
  verifiers/v1/types.py,sha256=-In5plgB3mQLqtRKHC3QDCPDvuruAqVd8WIjKrsGB_I,10578
18
- verifiers/v1/acp/__init__.py,sha256=Omle2zo7aly_7CCg1N9yQjQcJe4UpqTw9xVBP8_tuT8,13891
19
- verifiers/v1/acp/runner.py,sha256=OrJ1VSWTnNosV8OklqM2YdERlbF7olT9PHEdQfXvgGQ,14137
18
+ verifiers/v1/acp/__init__.py,sha256=gb2tkeq27oLHMPrCFOAGQi4iS2g3nzhpbC1kM_YCS8g,16479
19
+ verifiers/v1/acp/runner.py,sha256=sFsg0UgWeDjpjihejUidIV2yTapVj1Ap0cDTaEktjkI,15484
20
20
  verifiers/v1/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  verifiers/v1/cli/debug.py,sha256=OgzXCW9HaTNruZil4f70WP5tR6toKliKaVvV98C5qzE,11665
22
22
  verifiers/v1/cli/gepa.py,sha256=IyS3YZCCHVydb-OyPeGdtCYsz6OYqXXddXqYq8RLucM,4374
@@ -43,10 +43,10 @@ verifiers/v1/clients/eval.py,sha256=x_5E8XnhFu4v-k4yI8WWHd39rDkvB7mSEPCVAWL4AiE,
43
43
  verifiers/v1/clients/train.py,sha256=lSJTwHjwOET3D_CI_eYZSoXSQcv2H0K5sYJ7kD_kdw4,19489
44
44
  verifiers/v1/configs/__init__.py,sha256=X7u6X7B3ieD1HZl79Tv1kg-yPHbT0QLvAebaGqxVtvA,317
45
45
  verifiers/v1/configs/agent.py,sha256=Vut73R2EW8QuL5c0iTnNFC_g-Obvl8YUKsjlvkABuL0,5311
46
- verifiers/v1/configs/client.py,sha256=vl46nrayZa84zrPBuEVpJIjDjbmYUIrDIuR0vZvvugw,5212
46
+ verifiers/v1/configs/client.py,sha256=i45QOop2Wb-LQRpNgUZGTCT1_HpNmdcG41eQrGMgJKQ,5297
47
47
  verifiers/v1/configs/env.py,sha256=k47XBYYWLOf-8P1dbek3x_s0ucNb726xxoryG69DIr4,7256
48
48
  verifiers/v1/configs/harness.py,sha256=ZrR-Egzfft95oMoqQ78bAq9TqP882Q68M_47Ajw9lE8,2918
49
- verifiers/v1/configs/judge.py,sha256=WEwA8D4X5JSaIP3c-E2wPnvgByWeT6JRAn1d8gfiQ40,2142
49
+ verifiers/v1/configs/judge.py,sha256=3ZvxcTf-3clj8bDLyaqsMSKCRclTQCvbyb2qp3QSRD8,2166
50
50
  verifiers/v1/configs/retries.py,sha256=BgpGbdMBmjyjcIGnvb5VTJzKMDA6TdKil-PBQaRwGZM,1770
51
51
  verifiers/v1/configs/runtime.py,sha256=3LDAnKy-BotE6MqyT1l07Y_bAq9Wye1OU8JZ7lyUVvY,5474
52
52
  verifiers/v1/configs/select.py,sha256=_HdcepQaJ1sDCYcs7Vqx72npDwHGOgUjsLDZmcgnonU,4743
@@ -126,12 +126,13 @@ verifiers/v1/harnesses/utils/mcp.py,sha256=oNhKfLjAW-wScJcDIsW_HfuvKY-JkaGBBZrRL
126
126
  verifiers/v1/interception/__init__.py,sha256=5Kvo7tcBUVaa65s9A1NiOH3CPpzw-P5btXwxB4Nbbvs,4251
127
127
  verifiers/v1/interception/base.py,sha256=Mg16Y104lyZilF9shdivdqYoqz4OYp978i_myyrulu8,2529
128
128
  verifiers/v1/interception/pool.py,sha256=sk_zcdTV8EVRuzqlYMXxon_42tSDAlFvwcyOO2EF7hw,6473
129
- verifiers/v1/interception/server.py,sha256=DnPvQcZZX0JYvxrTLOQuJn4QFX5Z44edGjpvfdxZmRw,45773
129
+ verifiers/v1/interception/server.py,sha256=8cvco2PnW8nW7rVYZAndsmzU0I9uRowCoYziM1CqLeI,45916
130
130
  verifiers/v1/interception/tunnel/__init__.py,sha256=eVNZJszj6myrKhx9jmJz7VRd9qlfj0AUvUZwI2bXEj8,893
131
131
  verifiers/v1/interception/tunnel/base.py,sha256=DZB6uPLwM4Qy7n7m0vKeyg-Px87MhsmpYTpe2vpPEng,1998
132
132
  verifiers/v1/interception/tunnel/custom.py,sha256=yL4UbGf4bAuMsxk42mEP-t1wITQCT8BE9FHQIz0qRMA,1708
133
133
  verifiers/v1/interception/tunnel/prime.py,sha256=jVk_6xyrxXiHtsVFErcRNd4MoENdFbqzfC0i20nlizU,2803
134
- verifiers/v1/judges/__init__.py,sha256=MUIBWcx6c70BykrTDHhB0ITIyJPxXe6xDBou4VM7jDQ,286
134
+ verifiers/v1/judges/__init__.py,sha256=z-16awBqEZJrlVYIb9HD-rk12RaABh6jodJgwX4blnA,410
135
+ verifiers/v1/judges/decision.py,sha256=U8BKuzJVMWfX76fa6jaG_rmcOhzMkZoXSnkUuHKpoV4,3991
135
136
  verifiers/v1/judges/reference.py,sha256=iEVHw-iJ6dbwLOOqrvWrEMJOcUEYJ0YspT04rZz7Sd4,3868
136
137
  verifiers/v1/judges/reference.txt,sha256=Ej35kGXiT2uJ0LcHIeez49rCS_9uHmCQKoVdn93V6A8,353
137
138
  verifiers/v1/judges/rubric.py,sha256=HGQYI3xdq8I84Ce0_sDeNgk5JM597es8rOVPipjCyxU,12025
@@ -194,8 +195,8 @@ verifiers/v1/utils/scope.py,sha256=bzUEiWDOVdbj7dXOwGOlzsQ_-Xu0j9NR5TalQB-8TS8,8
194
195
  verifiers/v1/utils/score.py,sha256=493yJVMw8teCu9JxapxMFPFzI0hNUqdo0Y2nGW4kckk,6200
195
196
  verifiers/v1/utils/trace_store.py,sha256=RsCDonGR-bs-tvEstcppiJm3jFmAdIiG83fA5KtCbAw,2801
196
197
  verifiers/v1/utils/version.py,sha256=-obEo_-l9-D8FLef4hYxncOe-uJpxrM1g2Hig_37Sgs,1607
197
- verifiers-0.3.2.dev171.dist-info/METADATA,sha256=FiDjnhp-MuIN3vAbb1f9oBgKh5ZnPrX_9h-6B0YSe0E,4227
198
- verifiers-0.3.2.dev171.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
199
- verifiers-0.3.2.dev171.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
200
- verifiers-0.3.2.dev171.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
201
- verifiers-0.3.2.dev171.dist-info/RECORD,,
198
+ verifiers-0.3.2.dev185.dist-info/METADATA,sha256=DF9K7iZDqE_b9Sf8WSNbMwPW1mZlDdTxqrUZtxUQNrY,4227
199
+ verifiers-0.3.2.dev185.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
200
+ verifiers-0.3.2.dev185.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
201
+ verifiers-0.3.2.dev185.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
202
+ verifiers-0.3.2.dev185.dist-info/RECORD,,