1010import re
1111import socket
1212import sys
13+ import time
1314import traceback
1415import dataclasses
1516from dataclasses import dataclass
16- from typing import Any , Callable
17+ from typing import Any , AsyncIterator , Callable
1718
1819try :
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+
95131def 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+
121167def 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]:
150196def 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+
441507def 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+
536629def 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+
12031398async 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