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 +4 -3
- verifiers/v1/harnesses/rlm/harness.py +1 -1
- verifiers/v1/runtimes/__init__.py +9 -2
- verifiers/v1/runtimes/e2b.py +711 -0
- verifiers/v1/semantic.py +6 -26
- verifiers/v1/trace.py +0 -22
- {verifiers-0.3.2.dev166.dist-info → verifiers-0.3.2.dev168.dist-info}/METADATA +3 -1
- {verifiers-0.3.2.dev166.dist-info → verifiers-0.3.2.dev168.dist-info}/RECORD +11 -10
- {verifiers-0.3.2.dev166.dist-info → verifiers-0.3.2.dev168.dist-info}/WHEEL +0 -0
- {verifiers-0.3.2.dev166.dist-info → verifiers-0.3.2.dev168.dist-info}/entry_points.txt +0 -0
- {verifiers-0.3.2.dev166.dist-info → verifiers-0.3.2.dev168.dist-info}/licenses/LICENSE +0 -0
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
|
-
"""
|
|
93
|
+
"""Typed incoming edges from other turns, declared by the harness (for example
|
|
94
|
+
``subagent_return`` or ``subagent_cancel``).
|
|
94
95
|
|
|
95
|
-
|
|
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="
|
|
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
|
|
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``,
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
197
|
-
verifiers-0.3.2.
|
|
198
|
-
verifiers-0.3.2.
|
|
199
|
-
verifiers-0.3.2.
|
|
200
|
-
verifiers-0.3.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|