Skip to content

Commit 634959b

Browse files
committed
add --sandbox flag
1 parent 433f380 commit 634959b

8 files changed

Lines changed: 1618 additions & 18 deletions

File tree

scripts/eval-runner.py

Lines changed: 199 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
import re
1111
import socket
1212
import sys
13+
import time
1314
import traceback
1415
import dataclasses
1516
from dataclasses import dataclass
16-
from typing import Any, Callable
17+
from typing import Any, AsyncIterator, Callable
1718

1819
try:
1920
from braintrust import init_dataset, invoke, login
@@ -92,6 +93,41 @@ def close(self) -> None:
9293
self.sock.close()
9394

9495

96+
@dataclass
97+
class PullChannel:
98+
sock: socket.socket
99+
100+
def send(self, payload: Any) -> None:
101+
self.sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
102+
103+
async def lines(self) -> AsyncIterator[str]:
104+
buffer = ""
105+
while True:
106+
chunk = await asyncio.to_thread(self.sock.recv, 4096)
107+
if not chunk:
108+
break
109+
buffer += chunk.decode("utf-8")
110+
while True:
111+
newline = buffer.find("\n")
112+
if newline == -1:
113+
break
114+
line = buffer[:newline].strip()
115+
buffer = buffer[newline + 1 :]
116+
if line:
117+
yield line
118+
119+
trailing = buffer.strip()
120+
if trailing:
121+
yield trailing
122+
123+
def close(self) -> None:
124+
try:
125+
self.sock.shutdown(socket.SHUT_RDWR)
126+
except OSError:
127+
pass
128+
self.sock.close()
129+
130+
95131
def serialize_sse_event(event: str, data: Any) -> str:
96132
if isinstance(data, (dict, list)):
97133
data_str = json.dumps(data)
@@ -118,6 +154,16 @@ def create_sse_writer() -> SseWriter | None:
118154
return None
119155

120156

157+
def create_pull_channel() -> PullChannel | None:
158+
sock_path = os.getenv("BT_EVAL_PULL_SOCK")
159+
if not sock_path:
160+
return None
161+
162+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
163+
sock.connect(sock_path)
164+
return PullChannel(sock)
165+
166+
121167
def env_flag(name: str) -> bool:
122168
value = os.getenv(name)
123169
if value is None:
@@ -150,7 +196,7 @@ def parse_serialized_filters(serialized: str | None) -> list[EvalFilter]:
150196
def parse_dev_mode(value: str | None) -> str | None:
151197
if value is None or value == "":
152198
return None
153-
if value in {"list", "eval"}:
199+
if value in {"list", "eval", "rows"}:
154200
return value
155201
raise ValueError(f"Invalid BT_EVAL_DEV_MODE value: {value}")
156202

@@ -438,6 +484,26 @@ def parse_eval_request(raw: str | None) -> dict[str, Any]:
438484
return parsed
439485

440486

487+
def parse_eval_pull_request(raw: str | None) -> dict[str, Any]:
488+
if not raw:
489+
raise ValueError("Missing BT_EVAL_DEV_REQUEST_JSON")
490+
try:
491+
parsed = json.loads(raw)
492+
except json.JSONDecodeError as exc:
493+
raise ValueError(f"Invalid BT_EVAL_DEV_REQUEST_JSON: {exc}") from exc
494+
495+
if not isinstance(parsed, dict):
496+
raise ValueError("BT_EVAL_DEV_REQUEST_JSON must be a JSON object.")
497+
if not isinstance(parsed.get("name"), str) or not parsed["name"]:
498+
raise ValueError("Pull request must include a non-empty evaluator name.")
499+
500+
parameters = parsed.get("parameters")
501+
if parameters is not None and not isinstance(parameters, dict):
502+
raise ValueError("Pull request parameters must be an object.")
503+
504+
return parsed
505+
506+
441507
def resolve_eval_data(data: dict[str, Any]) -> Any:
442508
if "data" in data:
443509
return data["data"]
@@ -533,6 +599,33 @@ async def apply_sampling_to_data(data: Any, config: RunnerConfig) -> Any:
533599
return data
534600

535601

602+
async def call_evaluator_data(data: Any) -> tuple[Any, str | None]:
603+
data_result = data
604+
if inspect.isclass(data_result):
605+
data_result = data_result()
606+
if inspect.isfunction(data_result) or inspect.isroutine(data_result):
607+
data_result = data_result()
608+
if inspect.isawaitable(data_result):
609+
data_result = await data_result
610+
611+
base_experiment_name = None
612+
if isinstance(data_result, BaseExperiment):
613+
base_experiment_name = data_result.name
614+
615+
return data_result, base_experiment_name
616+
617+
618+
def to_async_iterator(value: Any) -> AsyncIterator[Any]:
619+
if inspect.isasyncgen(value):
620+
return value
621+
622+
async def to_async(it):
623+
for item in it:
624+
yield item
625+
626+
return to_async(value)
627+
628+
536629
def make_eval_scorer(
537630
score: dict[str, Any],
538631
project_id: str | None,
@@ -1200,6 +1293,108 @@ async def run_requested_eval(
12001293
return True
12011294

12021295

1296+
async def run_dataset_pull(
1297+
evaluator_instances: list[EvaluatorInstance],
1298+
config: RunnerConfig,
1299+
) -> bool:
1300+
channel = create_pull_channel()
1301+
if channel is None:
1302+
raise ValueError("Missing BT_EVAL_PULL_SOCK")
1303+
1304+
try:
1305+
request = parse_eval_pull_request(config.dev_request_json)
1306+
except Exception as exc:
1307+
channel.send({"type": "error", "message": str(exc)})
1308+
channel.close()
1309+
return False
1310+
1311+
target_name = request["name"]
1312+
evaluator_instance = next(
1313+
(candidate for candidate in evaluator_instances if candidate.evaluator.eval_name == target_name),
1314+
None,
1315+
)
1316+
if evaluator_instance is None:
1317+
channel.send({"type": "error", "message": f"Evaluator '{target_name}' not found"})
1318+
channel.close()
1319+
return False
1320+
1321+
evaluator = evaluator_instance.evaluator
1322+
try:
1323+
raw_data, _base_experiment_name = await call_evaluator_data(evaluator.data)
1324+
data_iterator = to_async_iterator(raw_data)
1325+
iterator = data_iterator.__aiter__()
1326+
trial_count = getattr(evaluator, "trial_count", 1)
1327+
try:
1328+
trial_count = int(trial_count)
1329+
except Exception:
1330+
trial_count = 1
1331+
if trial_count < 1:
1332+
trial_count = 1
1333+
1334+
max_concurrency = getattr(evaluator, "max_concurrency", None)
1335+
try:
1336+
max_concurrency = int(max_concurrency) if max_concurrency is not None else 10
1337+
except Exception:
1338+
max_concurrency = 10
1339+
if max_concurrency < 1:
1340+
max_concurrency = 1
1341+
1342+
experiment_name = getattr(evaluator, "experiment_name", None)
1343+
if not isinstance(experiment_name, str) or not experiment_name:
1344+
experiment_name = f"{evaluator.eval_name}-{int(time.time() * 1000)}"
1345+
1346+
channel.send(
1347+
{
1348+
"type": "ready",
1349+
"evaluator_name": evaluator.eval_name,
1350+
"max_concurrency": max_concurrency,
1351+
"experiment_name": experiment_name,
1352+
}
1353+
)
1354+
1355+
current_datum = None
1356+
trial_index = 0
1357+
async for line in channel.lines():
1358+
parsed = json.loads(line)
1359+
command_type = parsed.get("type") if isinstance(parsed, dict) else None
1360+
if command_type == "close":
1361+
break
1362+
if command_type != "next":
1363+
channel.send(
1364+
{
1365+
"type": "error",
1366+
"message": f"Unsupported pull command '{command_type}'",
1367+
}
1368+
)
1369+
break
1370+
1371+
if current_datum is None:
1372+
try:
1373+
current_datum = await iterator.__anext__()
1374+
trial_index = 0
1375+
except StopAsyncIteration:
1376+
channel.send({"type": "eof"})
1377+
continue
1378+
1379+
channel.send(
1380+
{
1381+
"type": "row",
1382+
"datum": current_datum,
1383+
"trial_index": trial_index,
1384+
}
1385+
)
1386+
trial_index += 1
1387+
if trial_index >= trial_count:
1388+
current_datum = None
1389+
except Exception as exc:
1390+
channel.send({"type": "error", "message": str(exc)})
1391+
channel.close()
1392+
return False
1393+
1394+
channel.close()
1395+
return True
1396+
1397+
12031398
async def run_once(
12041399
files: list[str],
12051400
no_send_logs: bool,
@@ -1221,6 +1416,8 @@ async def run_once(
12211416
return True
12221417
if config.dev_mode == "eval":
12231418
return await run_requested_eval(evaluators, reporters, no_send_logs, sse, config)
1419+
if config.dev_mode == "rows":
1420+
return await run_dataset_pull(evaluators, config)
12241421

12251422
if config.list_only:
12261423
for evaluator_instance in evaluators:

0 commit comments

Comments
 (0)