verifiers 0.3.2.dev166__py3-none-any.whl → 0.3.2.dev168__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/graph.py CHANGED
@@ -90,10 +90,11 @@ class MessageNode(BaseModel):
90
90
  tools: list[Tool] = Field(default_factory=list, exclude_if=lambda tools: not tools)
91
91
  """Tools rendered into this branch's prompt. Populated only on root nodes."""
92
92
  semantic_parents: list[ParentLink] = Field(default_factory=list)
93
- """Additional harness-declared parents in the semantic execution graph.
93
+ """Typed incoming edges from other turns, declared by the harness (for example
94
+ ``subagent_return`` or ``subagent_cancel``).
94
95
 
95
- Unlike ``parent``, these links do not imply an exact token prefix and therefore do
96
- not affect physical branch construction. A list permits multiple parents of the same
96
+ They record causal links, not ancestry: unlike ``parent`` they imply no token prefix,
97
+ do not affect physical branch construction, and may form loops. A list permits multiple parents of the same
97
98
  type, supports incremental appends, and preserves their advertised wire order; edge
98
99
  application prevents duplicate ``(node, type)`` links.
99
100
  """
@@ -69,7 +69,7 @@ class CompactionConfig(BaseConfig):
69
69
 
70
70
  class RLMHarnessConfig(HarnessConfig):
71
71
  version: str = Field(
72
- default="f18f10800f86f13913e19ef930abe5fcde4aa90f", min_length=1
72
+ default="c38e20b60c90bbf625274d3fde2b2a384f390a66", min_length=1
73
73
  )
74
74
  """Git ref (branch, tag, or commit) of nano-rlm to install. Must know every
75
75
  field this harness puts on the wire, i.e. be at least the default ref."""
@@ -25,6 +25,7 @@ from verifiers.v1.runtimes.docker import (
25
25
  PodmanRuntime,
26
26
  PodmanRuntimeInfo,
27
27
  )
28
+ from verifiers.v1.runtimes.e2b import E2BConfig, E2BRuntime, E2BRuntimeInfo
28
29
  from verifiers.v1.runtimes.modal import ModalConfig, ModalRuntime, ModalRuntimeInfo
29
30
  from verifiers.v1.runtimes.prime import (
30
31
  PrimeConfig,
@@ -44,7 +45,8 @@ RuntimeConfig = Annotated[
44
45
  | PodmanConfig
45
46
  | ApptainerConfig
46
47
  | PrimeConfig
47
- | ModalConfig,
48
+ | ModalConfig
49
+ | E2BConfig,
48
50
  Field(discriminator="type"),
49
51
  ]
50
52
 
@@ -54,7 +56,8 @@ RuntimeInfo = Annotated[
54
56
  | PodmanRuntimeInfo
55
57
  | ApptainerRuntimeInfo
56
58
  | PrimeRuntimeInfo
57
- | ModalRuntimeInfo,
59
+ | ModalRuntimeInfo
60
+ | E2BRuntimeInfo,
58
61
  Field(discriminator="type"),
59
62
  ]
60
63
 
@@ -67,6 +70,7 @@ def _runtime_cls(config: RuntimeConfig) -> type[Runtime]:
67
70
  "apptainer": ApptainerRuntime,
68
71
  "prime": PrimeRuntime,
69
72
  "modal": ModalRuntime,
73
+ "e2b": E2BRuntime,
70
74
  }[config.type]
71
75
 
72
76
 
@@ -109,6 +113,9 @@ __all__ = [
109
113
  "DockerConfig",
110
114
  "DockerRuntime",
111
115
  "DockerRuntimeInfo",
116
+ "E2BConfig",
117
+ "E2BRuntime",
118
+ "E2BRuntimeInfo",
112
119
  "ModalConfig",
113
120
  "ModalRuntime",
114
121
  "ModalRuntimeInfo",
@@ -0,0 +1,711 @@
1
+ """Remote E2B sandbox runtime.
2
+
3
+ `expose` (sandbox port -> public URL) uses E2B's built-in port proxy (`get_host`), so a
4
+ host-side harness/framework can reach a tool server hosted in the sandbox. The reverse
5
+ direction (a program in the sandbox reaching a host service) is the shared host-side
6
+ `Tunnel` (interception.tunnel), not the runtime's concern.
7
+
8
+ Two E2B SDK gaps shape the command plumbing here: every command runs through a login
9
+ shell (``bash -l -c``, so profile output can pollute the streams — fenced by the start
10
+ marker `_command` emits and consumers strip), and command output reaches callbacks as
11
+ UTF-8 text with ``errors="replace"`` (lossy for bytes — `open_process` swaps the
12
+ handle's decoders to latin-1 to recover exact bytes; see `E2BProcess`).
13
+ """
14
+
15
+ import asyncio
16
+ import codecs
17
+ import contextlib
18
+ import fcntl
19
+ import hashlib
20
+ import ipaddress
21
+ import json
22
+ import logging
23
+ import os
24
+ import re
25
+ import shlex
26
+ import uuid
27
+ from collections.abc import AsyncIterator
28
+ from contextlib import asynccontextmanager
29
+ from pathlib import PurePosixPath
30
+ from typing import ClassVar, Literal
31
+ from urllib.parse import urlsplit
32
+
33
+ from pydantic import Field, model_validator
34
+
35
+ from verifiers.v1.configs.runtime import NetworkPolicyConfig
36
+ from verifiers.v1.errors import SandboxError
37
+ from verifiers.v1.runtimes.base import (
38
+ SERVICE_PORT,
39
+ BaseRuntimeInfo,
40
+ ProgramResult,
41
+ Runtime,
42
+ RuntimeProcess,
43
+ )
44
+ from verifiers.v1.runtimes.limiters import creation_limiter
45
+ from verifiers.v1.utils.aio import run_shielded
46
+ from verifiers.v1.utils.paths import CACHE_DIR
47
+ from verifiers.v1.utils.scope import run_scope
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ _TEMPLATE_SCHEMA = 1
52
+ _TEMPLATE_LOCK_DIR = CACHE_DIR / "e2b-template-locks"
53
+
54
+ # Run as the image's root like the sibling runtimes; the SDK's default is an
55
+ # unprivileged `user`, which breaks root-only setup steps (package installs, restores).
56
+ _USER = "root"
57
+
58
+ # `run_program` can reconnect to the same pid without losing its file-backed output, so
59
+ # each wait-stream drop gets its own budget of reconnect attempts (backing off from
60
+ # `_WAIT_RECONNECT_BACKOFF` seconds, doubling per attempt): a rollout that runs for
61
+ # hours may survive any number of drops as long as each one reconnects. Live process
62
+ # streams cannot reconnect safely: E2B does not replay missed bytes.
63
+ _WAIT_RECONNECTS = 4
64
+ _WAIT_RECONNECT_BACKOFF = 0.25
65
+
66
+
67
+ def _template_resources(cpu: float, memory: float) -> tuple[int, int]:
68
+ """Validate and convert the resource units shared with TaskData.resources."""
69
+ cpu_value = float(cpu)
70
+ if not cpu_value.is_integer():
71
+ raise ValueError(f"E2B templates require whole CPU cores, got {cpu_value:g}")
72
+ cpu_count = int(cpu_value)
73
+ if cpu_count != 1 and cpu_count % 2:
74
+ raise ValueError(
75
+ f"E2B templates require 1 or an even number of CPU cores, got {cpu_count}"
76
+ )
77
+
78
+ memory_mb_value = float(memory) * 1024
79
+ if not memory_mb_value.is_integer() or int(memory_mb_value) % 2:
80
+ raise ValueError(
81
+ "E2B template memory must resolve to an even whole number of MB, "
82
+ f"got {memory:g} GB ({memory_mb_value:g} MB)"
83
+ )
84
+ return cpu_count, int(memory_mb_value)
85
+
86
+
87
+ _HOSTNAME = re.compile(
88
+ r"(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
89
+ r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
90
+ re.IGNORECASE,
91
+ )
92
+
93
+
94
+ def _is_address(rule: str) -> bool:
95
+ try:
96
+ ipaddress.ip_network(rule, strict=False)
97
+ except ValueError:
98
+ return False
99
+ return True
100
+
101
+
102
+ def _validate_egress_rules(allow: list[str], block: list[str]) -> None:
103
+ """Validate the two selector shapes E2B's network API accepts: allow rules take
104
+ hostnames (a leading ``*.`` wildcard included), IPs, and CIDRs; deny rules take IPs
105
+ and CIDRs only."""
106
+ for rule in block:
107
+ if not _is_address(rule):
108
+ raise ValueError(
109
+ f"E2B block rules must be IP addresses or CIDR blocks, got {rule!r}"
110
+ )
111
+ for rule in allow:
112
+ if not (_is_address(rule) or _HOSTNAME.fullmatch(rule)):
113
+ raise ValueError(
114
+ "E2B allow rules must be hostnames, wildcard hostnames, IP addresses, "
115
+ f"or CIDR blocks (no schemes, ports, or paths), got {rule!r}"
116
+ )
117
+
118
+
119
+ def _egress_update(config: "E2BConfig", routes: list[str] | None) -> dict:
120
+ """The complete egress policy for `update_network`, which replaces all rules
121
+ atomically. None restores unrestricted egress for another trusted setup phase;
122
+ otherwise `routes` (the interception and MCP endpoints) stay reachable alongside
123
+ the configured allowlist. E2B resolves an `allow_out` entry over a same-host
124
+ `deny_out` one and requires the deny-everything floor whenever `allow_out` names
125
+ domains, so an allowlist is expressed as allow + deny-everything and a blocklist
126
+ as deny only (everything else, framework routes included, stays reachable)."""
127
+ if routes is None:
128
+ return {"allow_internet_access": True}
129
+ if config.allow == ["*"]:
130
+ return {"deny_out": list(config.block)}
131
+ hosts = list(
132
+ dict.fromkeys(
133
+ host for route in routes if (host := urlsplit(route).hostname) is not None
134
+ )
135
+ )
136
+ entries = list(dict.fromkeys([*hosts, *config.allow]))
137
+ if not entries:
138
+ return {"allow_internet_access": False}
139
+ return {"allow_out": entries, "deny_out": ["0.0.0.0/0"]}
140
+
141
+
142
+ class E2BConfig(NetworkPolicyConfig):
143
+ """Requires an E2B Pro plan: sandboxes are created with E2B's 24-hour maximum
144
+ lifetime, which the Base plan caps at one hour (the API rejects the request)."""
145
+
146
+ type: Literal["e2b"] = "e2b"
147
+ image: str = "python:3.11-slim"
148
+ """Public Debian-based Docker image used to build a cached E2B template."""
149
+ workdir: str | None = None
150
+ """Working directory override; None uses the task's workdir, or /app."""
151
+ # TaskData.resources uses these units; non-default runtime config values take precedence.
152
+ cpu: float = Field(default=2.0, ge=1)
153
+ """CPU cores. E2B templates require 1 or an even whole number."""
154
+ memory: float = Field(default=1.0, ge=0.125)
155
+ """Memory in GB. The converted MB value must be a whole even number."""
156
+ disk: float = Field(default=5.0, gt=0)
157
+ """Advisory disk request in GB. E2B template builds have no disk-size knob, so this
158
+ is accepted (so a task can declare it without a warning) but not enforced."""
159
+ creates_per_sec: float | None = 1.0
160
+ """Pace sandbox creation to this many per second, enforced run-wide across every
161
+ env-server worker process (None/<= 0 disables it). Raise it to your plan's rate."""
162
+
163
+ @model_validator(mode="after")
164
+ def _validate_egress(self) -> "E2BConfig":
165
+ if self.network_restricted:
166
+ _validate_egress_rules(
167
+ [rule for rule in self.allow if rule != "*"],
168
+ [rule for rule in self.block if rule != "*"],
169
+ )
170
+ return self
171
+
172
+
173
+ class E2BRuntimeInfo(E2BConfig, BaseRuntimeInfo):
174
+ template: str | None = None
175
+
176
+
177
+ async def _queue_stream(
178
+ queue: asyncio.Queue[bytes | SandboxError | None],
179
+ ) -> AsyncIterator[bytes]:
180
+ while (chunk := await queue.get()) is not None:
181
+ if isinstance(chunk, SandboxError):
182
+ raise chunk
183
+ yield chunk
184
+
185
+
186
+ def _byte_handle(handle):
187
+ """Make a command handle deliver exact bytes to its callbacks.
188
+
189
+ The SDK decodes chunks as UTF-8 with ``errors="replace"`` (lossy for arbitrary
190
+ bytes) through a per-handle incremental decoder. Swapping it for latin-1 — a 1:1 map
191
+ of bytes onto U+00–U+FF — lets ``chunk.encode("latin-1")`` recover the bytes. The
192
+ swap is safe right after the handle is returned: the SDK constructs it after its
193
+ last await, so no chunk is decoded before the caller runs again.
194
+
195
+ TODO(e2b): drop this once the SDK grows a bytes mode for command output; it rides
196
+ on private attrs the guard can only prove exist, not that chunks still route
197
+ through them (they do on 2.35.0–2.46.x, hence the ``<3`` pin)."""
198
+ for name in ("_stdout_decoder", "_stderr_decoder"):
199
+ if not hasattr(handle, name):
200
+ raise SandboxError(
201
+ "e2b SDK command handle no longer exposes its stream decoders; "
202
+ "cannot deliver byte-accurate process output"
203
+ )
204
+ setattr(handle, name, codecs.getincrementaldecoder("latin-1")())
205
+ return handle
206
+
207
+
208
+ class E2BProcess(RuntimeProcess):
209
+ """Built in two steps: the queues (and their `on_*` callbacks) must exist before
210
+ `commands.run` is called with them, and the handle only exists after — so construct
211
+ first, then `attach` the started handle (made byte-exact by `_byte_handle`)."""
212
+
213
+ def __init__(self, sandbox, marker: bytes) -> None:
214
+ self._handle = None
215
+ self._sandbox = sandbox
216
+ self._marker = marker
217
+ # Pre-marker buffer per stream; None once the marker has been consumed.
218
+ self._prefix: dict[str, bytearray | None] = {
219
+ "stdout": bytearray(),
220
+ "stderr": bytearray(),
221
+ }
222
+ self._stdout_queue: asyncio.Queue[bytes | SandboxError | None] = asyncio.Queue()
223
+ self._stderr_queue: asyncio.Queue[bytes | SandboxError | None] = asyncio.Queue()
224
+ self.stdout = _queue_stream(self._stdout_queue)
225
+ self.stderr = _queue_stream(self._stderr_queue)
226
+ self._wait_task: asyncio.Task[int] | None = None
227
+ self._exit_code: int | None = None
228
+
229
+ def _payload(self, stream: str, chunk: str) -> bytes:
230
+ """Recover the raw bytes of a chunk and drop everything through the marker."""
231
+ data = chunk.encode("latin-1", errors="replace")
232
+ pending = self._prefix[stream]
233
+ if pending is None:
234
+ return data
235
+ # Resume the search where the last chunk left off (the marker can straddle
236
+ # a chunk boundary), so pathological pre-marker noise stays linear to scan.
237
+ search_from = max(0, len(pending) - len(self._marker) + 1)
238
+ pending.extend(data)
239
+ start = pending.find(self._marker, search_from)
240
+ if start < 0:
241
+ return b""
242
+ self._prefix[stream] = None
243
+ return bytes(pending[start + len(self._marker) :])
244
+
245
+ def on_stdout(self, chunk: str) -> None:
246
+ if data := self._payload("stdout", chunk):
247
+ self._stdout_queue.put_nowait(data)
248
+
249
+ def on_stderr(self, chunk: str) -> None:
250
+ if data := self._payload("stderr", chunk):
251
+ self._stderr_queue.put_nowait(data)
252
+
253
+ def attach(self, handle) -> None:
254
+ self._handle = handle
255
+ self._wait_task = asyncio.create_task(self._wait())
256
+
257
+ async def _wait(self) -> int:
258
+ # Importable here for sure: a live handle means the SDK import in `start` succeeded.
259
+ from e2b import CommandExitException
260
+
261
+ try:
262
+ self._exit_code = (await self._handle.wait()).exit_code
263
+ except CommandExitException as e:
264
+ self._exit_code = e.exit_code
265
+ except Exception as e:
266
+ with contextlib.suppress(Exception):
267
+ await self.kill()
268
+ error = SandboxError(f"e2b live process connection failed: {e}")
269
+ self._stdout_queue.put_nowait(error)
270
+ self._stderr_queue.put_nowait(error)
271
+ raise error from e
272
+ finally:
273
+ # A process that died before printing its marker produced only pre-marker
274
+ # output — the shell's own diagnosis of why it never started. Flush it so
275
+ # the failure is debuggable (mirrors run()'s missing-marker fallback).
276
+ for queue, stream in (
277
+ (self._stdout_queue, "stdout"),
278
+ (self._stderr_queue, "stderr"),
279
+ ):
280
+ if pending := self._prefix[stream]:
281
+ queue.put_nowait(bytes(pending))
282
+ queue.put_nowait(None)
283
+ return self._exit_code
284
+
285
+ async def write(self, data: bytes) -> None:
286
+ try:
287
+ await self._handle.send_stdin(data)
288
+ except Exception as e:
289
+ raise SandboxError(f"e2b live process stdin failed: {e}") from e
290
+
291
+ async def wait(self) -> int:
292
+ assert self._wait_task is not None
293
+ return await asyncio.shield(self._wait_task)
294
+
295
+ async def poll(self) -> int | None:
296
+ if self._wait_task is not None and self._wait_task.done():
297
+ return self._wait_task.result()
298
+ return self._exit_code
299
+
300
+ async def terminate(self) -> None:
301
+ await self._signal("TERM")
302
+
303
+ async def kill(self) -> None:
304
+ await self._signal("KILL")
305
+
306
+ async def _signal(self, signal: str) -> None:
307
+ if self._exit_code is not None:
308
+ return
309
+ try:
310
+ await self._sandbox.commands.run(
311
+ f"kill -{signal} {self._handle.pid}", user=_USER
312
+ )
313
+ except Exception as e:
314
+ if self._exit_code is None:
315
+ raise SandboxError(f"e2b live process signal failed: {e}") from e
316
+
317
+
318
+ @asynccontextmanager
319
+ async def _template_lock(name: str):
320
+ """Serialize a deterministic template build across this user's worker processes."""
321
+ _TEMPLATE_LOCK_DIR.mkdir(parents=True, exist_ok=True)
322
+ fd = os.open(_TEMPLATE_LOCK_DIR / f"{name}.lock", os.O_CREAT | os.O_RDWR, 0o600)
323
+ try:
324
+ # Wait without occupying executor threads needed by the lock holder.
325
+ while True:
326
+ try:
327
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
328
+ break
329
+ except BlockingIOError:
330
+ await asyncio.sleep(0.1)
331
+ yield
332
+ finally:
333
+ # Closing releases the lock, including when the task is cancelled.
334
+ os.close(fd)
335
+
336
+
337
+ class E2BRuntime(Runtime):
338
+ is_local: ClassVar[bool] = False
339
+
340
+ def __init__(self, config: E2BConfig, name: str | None = None) -> None:
341
+ super().__init__(name)
342
+ self.config = config.model_copy(update={"workdir": config.workdir or "/app"})
343
+ self.info = E2BRuntimeInfo(**self.config.model_dump())
344
+ self._sandbox = None
345
+ # Start marker for `_command`'s login-shell fence; random so no login-shell
346
+ # output can contain it (see `_command`).
347
+ self._marker = f"vf-start-{uuid.uuid4().hex}"
348
+
349
+ @property
350
+ def published_port(self) -> int | None:
351
+ return SERVICE_PORT
352
+
353
+ def _template_name(self) -> str:
354
+ definition = json.dumps(
355
+ {
356
+ "schema": _TEMPLATE_SCHEMA,
357
+ "image": self.config.image,
358
+ "cpu": self.config.cpu,
359
+ "memory": self.config.memory,
360
+ },
361
+ sort_keys=True,
362
+ separators=(",", ":"),
363
+ )
364
+ digest = hashlib.sha256(definition.encode()).hexdigest()[:24]
365
+ return f"vf-{digest}"
366
+
367
+ async def _ensure_template(self) -> str:
368
+ from e2b import AsyncTemplate, Template
369
+
370
+ # Validated here, not in `__init__`: task-level resources arrive unvalidated
371
+ # (model_copy) and a bad task should fail its own rollout, like on other providers.
372
+ cpu_count, memory_mb = _template_resources(self.config.cpu, self.config.memory)
373
+ name = self._template_name()
374
+ async with _template_lock(name):
375
+ # An alias is registered before its build finishes; an explicit tag
376
+ # exists only when it resolves to a ready build.
377
+ if not await AsyncTemplate.exists(f"{name}:default"):
378
+ logger.info(
379
+ "e2b: building template %s from image %s", name, self.config.image
380
+ )
381
+ template = Template().from_image(self.config.image)
382
+ await AsyncTemplate.build(
383
+ template,
384
+ name,
385
+ cpu_count=cpu_count,
386
+ memory_mb=memory_mb,
387
+ )
388
+ return name
389
+
390
+ async def start(self) -> None:
391
+ try:
392
+ from e2b import AsyncSandbox
393
+ except ModuleNotFoundError as e:
394
+ raise ModuleNotFoundError(
395
+ "E2BRuntime requires the E2B SDK; install `verifiers[e2b]`."
396
+ ) from e
397
+
398
+ try:
399
+ template = await self._ensure_template()
400
+ self.info.template = template
401
+
402
+ async def _create() -> None:
403
+ async with (
404
+ creation_limiter(
405
+ self.config.creates_per_sec, "e2b-sandbox", run_scope()
406
+ )
407
+ or contextlib.nullcontext()
408
+ ):
409
+ # Created unrestricted: setup (uv installs, task staging) needs open
410
+ # egress; `prepare_execution` locks the policy down before the agent.
411
+ self._sandbox = await AsyncSandbox.create(
412
+ template,
413
+ # Maximum lifetime of any sandbox (Pro; Base's 1h cap rejects it).
414
+ timeout=24 * 60 * 60,
415
+ metadata={"runtime": "verifiers", "name": self.name},
416
+ )
417
+ # The atexit backstop kills by id, so record it the moment the sandbox
418
+ # exists — the cancellation `run_shielded` re-raises can keep the code
419
+ # after it from ever running.
420
+ self.info.id = self._sandbox.sandbox_id
421
+
422
+ # Finish an accepted create request even if the rollout is cancelled so the
423
+ # surrounding provision_runtime finally can see and kill the sandbox.
424
+ await run_shielded(_create())
425
+ logger.info(
426
+ "e2b: sandbox %s up (image=%s, template=%s)",
427
+ self.info.id,
428
+ self.config.image,
429
+ template,
430
+ )
431
+ # The template is workdir-agnostic (one build serves every task workdir);
432
+ # every command passes `cwd`, so only the directory itself is needed.
433
+ await self._sandbox.files.make_dir(self.config.workdir, user=_USER)
434
+ except Exception as e:
435
+ raise SandboxError(f"e2b sandbox provisioning failed: {e}") from e
436
+
437
+ async def prepare_execution(self, routes: list[str] | None) -> None:
438
+ """Apply the host policy after setup; the update replaces all rules atomically."""
439
+ if not self.network_restricted:
440
+ return
441
+ network = _egress_update(self.config, routes)
442
+ try:
443
+ await self._sandbox.update_network(network)
444
+ except Exception as e:
445
+ raise SandboxError(f"e2b egress policy failed: {e}") from e
446
+ logger.info(
447
+ "e2b: egress policy applied on sandbox %s (%s)", self.info.id, network
448
+ )
449
+
450
+ def _command(
451
+ self, argv: list[str], env: dict[str, str]
452
+ ) -> tuple[str, dict[str, str]]:
453
+ """The wrapped command and its env.
454
+
455
+ E2B commands run through a login shell (the SDK hardcodes ``bash -l -c``),
456
+ which brings two hazards the wrapper fences off: profile initialization can
457
+ print to stdout/stderr (banners in /etc/profile.d) and it re-derives PATH. The
458
+ runtime's start marker, printed on both streams right before ``exec``, lets
459
+ consumers drop everything the shell produced; ``set +x`` comes first so a
460
+ profile that enabled tracing cannot echo the marker or the exec line to stderr
461
+ after it. The caller-supplied PATH is restored after initialization so it has
462
+ the same precedence as on other runtimes."""
463
+ command_env = self.process_env(env)
464
+ emit = f"printf %s {shlex.quote(self._marker)}"
465
+ command = f"{emit}; {emit} >&2; exec {shlex.join(argv)}"
466
+ if "PATH" in command_env:
467
+ command_env["VF_RUNTIME_PATH"] = command_env.pop("PATH")
468
+ command = f'export PATH="$VF_RUNTIME_PATH"; {command}'
469
+ return f"set +x; {command}", command_env
470
+
471
+ def _after_marker(self, text: str) -> str:
472
+ """Drop login-shell noise: everything through the start marker. Text without
473
+ the marker (the shell died before ``exec``) passes through whole — it is the
474
+ diagnosis of why the command never ran."""
475
+ _, sep, tail = text.partition(self._marker)
476
+ return tail if sep else text
477
+
478
+ async def run(self, argv: list[str], env: dict[str, str]) -> ProgramResult:
479
+ from e2b import CommandExitException # importable once `start` succeeded
480
+
481
+ command, command_env = self._command(argv, env)
482
+ try:
483
+ result = await self._sandbox.commands.run(
484
+ command,
485
+ envs=command_env,
486
+ cwd=self.config.workdir,
487
+ user=_USER,
488
+ timeout=0, # no command deadline; the agent's own timeouts govern
489
+ )
490
+ except CommandExitException as e: # non-zero exit is a result, not a failure
491
+ result = e
492
+ except Exception as e:
493
+ raise SandboxError(f"e2b exec failed: {e}") from e
494
+ return ProgramResult(
495
+ result.exit_code,
496
+ self._after_marker(result.stdout or ""),
497
+ self._after_marker(result.stderr or ""),
498
+ )
499
+
500
+ async def _finished_status(self, status_path: str) -> str | None:
501
+ """The wrapper's recorded exit status, or None while the program still runs.
502
+ Read rather than test existence: the shell truncates the file before printf
503
+ fills it, so only non-empty content proves the program completed."""
504
+ try:
505
+ status = await self._sandbox.files.read(status_path, user=_USER)
506
+ except Exception: # noqa: BLE001 - unreadable means "not finished yet"
507
+ return None
508
+ return status.strip() or None
509
+
510
+ async def _reconnect(self, pid: int, status_path: str, cause: Exception):
511
+ """Reattach to a durable program's wait stream after it dropped: the handle on
512
+ success, None once the program is found to have finished (its status file is
513
+ written) — completion beats a dead stream. Raises after `_WAIT_RECONNECTS`
514
+ consecutive failures; a drop that reconnects consumes none of a later drop's
515
+ budget, so a long rollout survives any number of transient drops."""
516
+ error: Exception = cause
517
+ for attempt in range(_WAIT_RECONNECTS):
518
+ await asyncio.sleep(_WAIT_RECONNECT_BACKOFF * 2**attempt)
519
+ try:
520
+ return await self._sandbox.commands.connect(pid, timeout=0)
521
+ except Exception as reconnect_error: # noqa: BLE001 - re-raised after the loop
522
+ # The pid may be gone because the program finished during the backoff.
523
+ if await self._finished_status(status_path) is not None:
524
+ return None
525
+ error = reconnect_error
526
+ raise SandboxError(
527
+ f"e2b durable program connection failed: {cause}; reconnect failed: {error}"
528
+ ) from error
529
+
530
+ async def run_program(self, argv: list[str], env: dict[str, str]) -> ProgramResult:
531
+ """Run the rollout through a durable E2B process without ever replaying it."""
532
+ from e2b import CommandExitException
533
+
534
+ prefix = f"/tmp/vf-program-{uuid.uuid4().hex}"
535
+ stdout_path, stderr_path, status_path = (
536
+ f"{prefix}.stdout",
537
+ f"{prefix}.stderr",
538
+ f"{prefix}.status",
539
+ )
540
+ wrapper = (
541
+ 'out=$1; err=$2; status=$3; shift 3; "$@" >"$out" 2>"$err"; '
542
+ 'rc=$?; printf "%s\\n" "$rc" >"$status"; exit 0'
543
+ )
544
+ command, command_env = self._command(
545
+ [
546
+ "sh",
547
+ "-c",
548
+ wrapper,
549
+ "vf-program",
550
+ stdout_path,
551
+ stderr_path,
552
+ status_path,
553
+ *argv,
554
+ ],
555
+ env,
556
+ )
557
+ try:
558
+ handle = await self._sandbox.commands.run(
559
+ command,
560
+ background=True,
561
+ envs=command_env,
562
+ cwd=self.config.workdir,
563
+ user=_USER,
564
+ stdin=False,
565
+ timeout=0,
566
+ )
567
+ # The status file, not the wait stream, is the source of truth: on any
568
+ # stream failure — including a reconnect finding the pid already gone
569
+ # because the program finished during the backoff — completion wins.
570
+ status: str | None = None
571
+ while True:
572
+ try:
573
+ await handle.wait()
574
+ break
575
+ except CommandExitException as e:
576
+ # The wrapper always exits 0, so this is the login shell failing
577
+ # before it ran (`exec` failure, a profile that exits): as in
578
+ # `run`, its exit code and streams are the result.
579
+ return ProgramResult(
580
+ e.exit_code,
581
+ self._after_marker(e.stdout or ""),
582
+ self._after_marker(e.stderr or ""),
583
+ )
584
+ except Exception as e: # noqa: BLE001 - a dropped stream; _reconnect re-raises
585
+ status = await self._finished_status(status_path)
586
+ if status is not None:
587
+ break
588
+ handle = await self._reconnect(handle.pid, status_path, e)
589
+ if handle is None: # finished while the stream was down
590
+ break
591
+ stdout, stderr = await asyncio.gather(
592
+ self._sandbox.files.read(stdout_path, user=_USER),
593
+ self._sandbox.files.read(stderr_path, user=_USER),
594
+ )
595
+ if status is None:
596
+ status = (
597
+ await self._sandbox.files.read(status_path, user=_USER)
598
+ ).strip()
599
+ return ProgramResult(int(status), stdout, stderr)
600
+ except SandboxError:
601
+ raise
602
+ except Exception as e:
603
+ raise SandboxError(f"e2b durable program failed: {e}") from e
604
+ finally:
605
+ with contextlib.suppress(Exception):
606
+ await asyncio.gather(
607
+ self._sandbox.files.remove(stdout_path, user=_USER),
608
+ self._sandbox.files.remove(stderr_path, user=_USER),
609
+ self._sandbox.files.remove(status_path, user=_USER),
610
+ )
611
+
612
+ async def open_process(
613
+ self, argv: list[str], env: dict[str, str]
614
+ ) -> RuntimeProcess:
615
+ command, command_env = self._command(argv, env)
616
+ process = E2BProcess(self._sandbox, self._marker.encode())
617
+ try:
618
+ handle = await self._sandbox.commands.run(
619
+ command,
620
+ background=True,
621
+ envs=command_env,
622
+ cwd=self.config.workdir,
623
+ user=_USER,
624
+ on_stdout=process.on_stdout,
625
+ on_stderr=process.on_stderr,
626
+ stdin=True,
627
+ timeout=0,
628
+ )
629
+ except Exception as e:
630
+ raise SandboxError(f"e2b live process failed to start: {e}") from e
631
+ try:
632
+ process.attach(_byte_handle(handle))
633
+ except SandboxError:
634
+ with contextlib.suppress(Exception):
635
+ await handle.kill()
636
+ raise
637
+ return process
638
+
639
+ async def run_background(
640
+ self, argv: list[str], env: dict[str, str], log: str
641
+ ) -> None:
642
+ inner = f"nohup {shlex.join(argv)} > {shlex.quote(log)} 2>&1 &"
643
+ result = await self.run(["sh", "-c", inner], env)
644
+ if result.exit_code != 0:
645
+ raise SandboxError(f"e2b background launch failed: {result.stderr.strip()}")
646
+
647
+ def _abs(self, path: str) -> str:
648
+ if path.startswith("/"):
649
+ return path
650
+ return f"{self.config.workdir.rstrip('/')}/{path}"
651
+
652
+ async def _read(self, path: str, max_bytes: int | None = None) -> bytes:
653
+ if max_bytes is not None:
654
+ return await super()._read(path, max_bytes)
655
+ try:
656
+ return bytes(
657
+ await self._sandbox.files.read(
658
+ self._abs(path), format="bytes", user=_USER
659
+ )
660
+ )
661
+ except Exception as e:
662
+ raise SandboxError(f"read {path!r}: {e}") from e
663
+
664
+ async def write(self, path: str, data: bytes) -> None:
665
+ target = self._abs(path)
666
+ try:
667
+ await self._sandbox.files.make_dir(
668
+ str(PurePosixPath(target).parent), user=_USER
669
+ )
670
+ await self._sandbox.files.write(target, data, user=_USER)
671
+ except Exception as e:
672
+ raise SandboxError(f"write {path!r}: {e}") from e
673
+
674
+ async def expose(self, port: int) -> str | None:
675
+ # Publish a server hosted IN the sandbox: E2B proxies every sandbox port at a
676
+ # public HTTPS URL derived locally from the sandbox id — nothing to declare up
677
+ # front or tear down.
678
+ if self._sandbox is None:
679
+ return None
680
+ return f"https://{self._sandbox.get_host(port)}"
681
+
682
+ def cleanup(self) -> None:
683
+ # Synchronous atexit backstop (the async API can't run once the loop is gone): kill
684
+ # the sandbox by id via E2B's sync API so the paid resource isn't left to its
685
+ # timeout. Idempotent — the async `stop` handles the normal path, and a second kill
686
+ # is a no-op on E2B's side.
687
+ if self._sandbox is None or self.info.id is None:
688
+ return
689
+ from e2b import Sandbox
690
+
691
+ try:
692
+ Sandbox.kill(self.info.id)
693
+ except Exception: # noqa: BLE001 - a later cleanup call may retry
694
+ return
695
+ self._sandbox = None
696
+
697
+ async def teardown(self) -> None:
698
+ # Best-effort, idempotent teardown on the normal path: kill the sandbox (the costly
699
+ # resource) via the async API. Runs via `stop`, shielded from cancellation, so it
700
+ # fires on success, error, and Ctrl-C. `_sandbox` — the atexit backstop's key — is
701
+ # consumed only after the kill attempt: if loop death (second Ctrl-C) truncates the
702
+ # await, the backstop can still kill by id.
703
+ sandbox = self._sandbox
704
+ if sandbox is None:
705
+ return
706
+ try:
707
+ await sandbox.kill()
708
+ except Exception as e: # noqa: BLE001 - provider teardown is best-effort
709
+ logger.warning("e2b: failed to kill sandbox %s: %s", self.info.id, e)
710
+ return
711
+ self._sandbox = None
verifiers/v1/semantic.py CHANGED
@@ -60,11 +60,14 @@ class ParentLink(BaseModel):
60
60
 
61
61
 
62
62
  class SemanticEdgeSet(BaseModel):
63
- """A harness-published set of semantic edges over logical request IDs.
63
+ """A harness-published list of typed edges between logical request IDs.
64
+
65
+ Each edge records that one turn caused or fed another. They are not ancestry, so
66
+ they may form loops: a cancel can land on the child turn whose report triggered it.
64
67
 
65
68
  Edge labels are intentionally extensible. Initial harnesses use ``continuation``,
66
- ``compaction_attempt``, ``compaction``, ``subagent_call``, and ``subagent_return``;
67
- consumers must preserve unknown labels.
69
+ ``compaction_attempt``, ``compaction``, ``subagent_call``, ``subagent_return``,
70
+ ``agent_message``, and ``subagent_cancel``; consumers must preserve unknown labels.
68
71
  """
69
72
 
70
73
  model_config = ConfigDict(extra="forbid", strict=True)
@@ -74,8 +77,6 @@ class SemanticEdgeSet(BaseModel):
74
77
  @model_validator(mode="after")
75
78
  def validate_edges(self) -> SemanticEdgeSet:
76
79
  identities: set[tuple[str, str, str]] = set()
77
- children: dict[str, list[str]] = {}
78
- nodes: set[str] = set()
79
80
  for edge in self.edges:
80
81
  identity = (
81
82
  edge.source_request_id,
@@ -85,27 +86,6 @@ class SemanticEdgeSet(BaseModel):
85
86
  if identity in identities:
86
87
  raise ValueError(f"duplicate semantic edge: {identity!r}")
87
88
  identities.add(identity)
88
- children.setdefault(edge.source_request_id, []).append(
89
- edge.target_request_id
90
- )
91
- nodes.update((edge.source_request_id, edge.target_request_id))
92
-
93
- indegree = dict.fromkeys(nodes, 0)
94
- for targets in children.values():
95
- for target in targets:
96
- indegree[target] += 1
97
-
98
- stack = [node for node, degree in indegree.items() if degree == 0]
99
- visited = 0
100
- while stack:
101
- node = stack.pop()
102
- visited += 1
103
- for child in children.get(node, ()):
104
- indegree[child] -= 1
105
- if indegree[child] == 0:
106
- stack.append(child)
107
- if visited != len(nodes):
108
- raise ValueError("semantic edge cycle detected")
109
89
  return self
110
90
 
111
91
 
verifiers/v1/trace.py CHANGED
@@ -568,7 +568,6 @@ class Trace(BaseModel, Generic[DataT, StateT, AgentConfigT]):
568
568
 
569
569
  resolved_identities: set[tuple[int, int, str]] = set()
570
570
  additions: list[tuple[int, ParentLink]] = []
571
- pending_parents: dict[int, list[ParentLink]] = {}
572
571
  for edge in edge_set.edges:
573
572
  endpoints: list[int] = []
574
573
  for request_id in (
@@ -592,28 +591,7 @@ class Trace(BaseModel, Generic[DataT, StateT, AgentConfigT]):
592
591
  if link in self.nodes[target].semantic_parents:
593
592
  continue
594
593
 
595
- # Adding source -> target creates a cycle exactly when target is already an
596
- # ancestor of source. Walk parent links directly so existing nodes and links
597
- # are never rebuilt as cumulative ACP edge sets arrive.
598
- stack = [source]
599
- visited: set[int] = set()
600
- while stack:
601
- node_id = stack.pop()
602
- if node_id == target:
603
- raise ValueError(
604
- "semantic edges create a cycle in the message graph"
605
- )
606
- if node_id in visited:
607
- continue
608
- visited.add(node_id)
609
- node = self.nodes[node_id]
610
- if node.parent is not None:
611
- stack.append(node.parent)
612
- stack.extend(parent.node for parent in node.semantic_parents)
613
- stack.extend(parent.node for parent in pending_parents.get(node_id, ()))
614
-
615
594
  additions.append((target, link))
616
- pending_parents.setdefault(target, []).append(link)
617
595
 
618
596
  for target, link in additions:
619
597
  self.nodes[target].semantic_parents.append(link)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifiers
3
- Version: 0.3.2.dev166
3
+ Version: 0.3.2.dev168
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
@@ -46,6 +46,8 @@ Requires-Dist: tenacity>=8.5.0
46
46
  Requires-Dist: tomli-w>=1.0.0
47
47
  Requires-Dist: typing-extensions>=4.12.2
48
48
  Requires-Dist: uvicorn>=0.52.0
49
+ Provides-Extra: e2b
50
+ Requires-Dist: e2b<3,>=2.35.0; extra == 'e2b'
49
51
  Provides-Extra: harbor
50
52
  Requires-Dist: harbor==0.21.0; (python_full_version >= '3.12') and extra == 'harbor'
51
53
  Provides-Extra: modal
@@ -4,16 +4,16 @@ verifiers/v1/agent.py,sha256=746LE0HE4DPyxVcgEkAAs8z9qjsR7CQncfKhT1h4jek,32433
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
- verifiers/v1/graph.py,sha256=4EA1TzUBLf9HycfSifKI5J3XkeJzYjbsszZRVwXSGP0,41420
7
+ verifiers/v1/graph.py,sha256=LW0pJRxCCOXZa2KwB0edHIIqmRrzLFBDNyEeOFVskl8,41502
8
8
  verifiers/v1/harness.py,sha256=AcsCmNHB1UQ75nD7RUawsb65q63NdkcMykD0E6oRDOA,15957
9
9
  verifiers/v1/judge.py,sha256=uqn-djkK2_7Q3kOjkDo6ipOvV_HOvuLa9y6AoDjRux8,9618
10
10
  verifiers/v1/rollout.py,sha256=WiMCJyAypsR1OaXvzJQx7G-T67v__AnnDNoXotuI_VE,25444
11
- verifiers/v1/semantic.py,sha256=VQ_buw6Ya6QtRoh_fiYI5Avs5RmIQCfWH0H0bQc4aoc,4704
11
+ verifiers/v1/semantic.py,sha256=tX8FYEceuMYauuB3Bc8jLDMS2eR06JjLjTo66R190Fg,4053
12
12
  verifiers/v1/session.py,sha256=qVMgKmqkItemR3uYUDAFuDz7huP593kHQbge8FlRba8,28420
13
13
  verifiers/v1/state.py,sha256=EckF2bWp-vV4b1jYJ9sLI5xrfGuI5spIgYYwW926toI,595
14
14
  verifiers/v1/task.py,sha256=6-Tw0iBLsc4hinvVPl_b2EUX8unAAnxtHMXNSIHXsnU,12886
15
15
  verifiers/v1/taskset.py,sha256=LE_3ogL7itU2Amk-N20cHkZ7Ev6MznJ0HpC8B1xb4w0,9482
16
- verifiers/v1/trace.py,sha256=tgzkft4nKF4QGeGXwoqkfXjaUbG65Nn1oBFzMUFp-u0,32234
16
+ verifiers/v1/trace.py,sha256=Npc-TrBh-B3H0OvtfA5X31VAlOhfgLDKGdXohqC2uIk,31156
17
17
  verifiers/v1/types.py,sha256=-In5plgB3mQLqtRKHC3QDCPDvuruAqVd8WIjKrsGB_I,10578
18
18
  verifiers/v1/acp/__init__.py,sha256=Omle2zo7aly_7CCg1N9yQjQcJe4UpqTw9xVBP8_tuT8,13891
19
19
  verifiers/v1/acp/runner.py,sha256=OrJ1VSWTnNosV8OklqM2YdERlbF7olT9PHEdQfXvgGQ,14137
@@ -113,7 +113,7 @@ verifiers/v1/harnesses/pi/harness.py,sha256=ysKOLSRQnbPkMtwvuUoJU7Hd4uM4bDF1duD0
113
113
  verifiers/v1/harnesses/prime_agent/__init__.py,sha256=xfPC4RrlnyYWaf38tPAOZq4YL6M55lMpj5BoRLmaK8I,171
114
114
  verifiers/v1/harnesses/prime_agent/harness.py,sha256=Zs_f5H2hQgDzUS4zwArM5UxCYJI5Zo0ELmayn1oBkK0,10663
115
115
  verifiers/v1/harnesses/rlm/__init__.py,sha256=hwx51xTdEWzzYgMj-p4ETNcptoub3IPMDbEdJ4uB8Ns,122
116
- verifiers/v1/harnesses/rlm/harness.py,sha256=NFJ3u1xC06d9NwjnxFD8cCqIXoGjAIwhmJly-hXSvMA,13538
116
+ verifiers/v1/harnesses/rlm/harness.py,sha256=wyK3f1jYYnJniz9SCJBI8j2oUGycU22HdQN6zAf34ME,13538
117
117
  verifiers/v1/harnesses/terminus_2/__init__.py,sha256=l1pwfg1HyVDEqT9efdnFWcXqaZwtKwe7TrplkyxZSA0,166
118
118
  verifiers/v1/harnesses/terminus_2/harness.py,sha256=cKQJluW01mkxEccTLFwSWsVMavyiwq_w-NA4Hb_JkGU,3052
119
119
  verifiers/v1/harnesses/terminus_2/program.py,sha256=X8MZdyItU3gbXGXnGOlJ9LWcUTnqIDaxlhkZvoRiqKY,2703
@@ -140,10 +140,11 @@ verifiers/v1/mcp/__init__.py,sha256=pm8ZWBiL1q1tY4wHvJzM_QrZ9oR8aGm-bIRWiRJsgHI,
140
140
  verifiers/v1/mcp/launch.py,sha256=r2j1hxMCZtOOjbOFBCZpt1_Zpo56e83HFMg_4sB_kA4,25916
141
141
  verifiers/v1/mcp/server.py,sha256=Jqi8OMT8DnoZegXqM3UNNRzhVnbdrPEwx0DjOJo_K2A,13256
142
142
  verifiers/v1/mcp/toolset.py,sha256=G2wzNdEMGLXoQUcrwF0TartJkqokNPI3o7YUOAaaa6U,1007
143
- verifiers/v1/runtimes/__init__.py,sha256=JQlQ029J14asfYb-UxpENshMyPztWbSL0-ZqSyD6Xtk,3318
143
+ verifiers/v1/runtimes/__init__.py,sha256=zOG9BYsK19VRNqvggqh3f6g1_UGIq7VjIBcK8kDlmnQ,3515
144
144
  verifiers/v1/runtimes/apptainer.py,sha256=Ru9QJna_l0goIzg71G-4RU_D-7wZUNz139S5TEvbzog,8211
145
145
  verifiers/v1/runtimes/base.py,sha256=GN2vTW3oSYpRcop_DRQYi7X31BG4NrzMrnkNiWSDp64,18160
146
146
  verifiers/v1/runtimes/container.py,sha256=_hNhGOo1Zvorj7THSj2UrZTkgewySUVJe4Kd777-QnI,12563
147
+ verifiers/v1/runtimes/e2b.py,sha256=yBeocAutsv2l5x-C5IMqrlgBz6Cb-t6vWbX4qTjlztE,30306
147
148
  verifiers/v1/runtimes/limiters.py,sha256=rOfQJcMdYcLSKq8AL9O09BfGOLqpYmNgrCxXXNcHLmU,2849
148
149
  verifiers/v1/runtimes/modal.py,sha256=ZHA4vtNIZwTV-bnVUlambgc8l34j_ZulvtrwvvkE2Kg,17945
149
150
  verifiers/v1/runtimes/prime.py,sha256=PCakstDt-gdEUI0d5xM5dEFgn3yQie0RmVsXnUPkjNQ,19230
@@ -193,8 +194,8 @@ verifiers/v1/utils/scope.py,sha256=bzUEiWDOVdbj7dXOwGOlzsQ_-Xu0j9NR5TalQB-8TS8,8
193
194
  verifiers/v1/utils/score.py,sha256=493yJVMw8teCu9JxapxMFPFzI0hNUqdo0Y2nGW4kckk,6200
194
195
  verifiers/v1/utils/trace_store.py,sha256=RsCDonGR-bs-tvEstcppiJm3jFmAdIiG83fA5KtCbAw,2801
195
196
  verifiers/v1/utils/version.py,sha256=-obEo_-l9-D8FLef4hYxncOe-uJpxrM1g2Hig_37Sgs,1607
196
- verifiers-0.3.2.dev166.dist-info/METADATA,sha256=PR5ypxpzJMtmrYgaGC5NjI0RT2i-ArYUhlpYVlfZ_hg,4161
197
- verifiers-0.3.2.dev166.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
198
- verifiers-0.3.2.dev166.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
199
- verifiers-0.3.2.dev166.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
200
- verifiers-0.3.2.dev166.dist-info/RECORD,,
197
+ verifiers-0.3.2.dev168.dist-info/METADATA,sha256=gb78e_KMSGlVaIhDBXpMS56UnLRgcLe5rtTP4aah5Do,4227
198
+ verifiers-0.3.2.dev168.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
199
+ verifiers-0.3.2.dev168.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
200
+ verifiers-0.3.2.dev168.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
201
+ verifiers-0.3.2.dev168.dist-info/RECORD,,