@@ -377,10 +377,31 @@ private <I, O> void handleStreamingEval(
377377 BraintrustUtils .createProjectURI (
378378 braintrust .config ().appUrl (), orgName , projectName )
379379 .toASCIIString ();
380- final var experimentUrl = projectUrl + "/experiments/" + experimentName ;
381380
382381 var tracer = BraintrustTracing .getTracer ();
383382
383+ // A remote eval can be triggered two ways:
384+ // 1. Playground run: the request carries a `parent` object (playground_id). We
385+ // stream per-case progress under that parent (handled by the loop below).
386+ // 2. Experiment "snapshot": no such parent (instead experiment_name + project).
387+ // We run a standard Eval, which creates a real experiment and emits standard
388+ // spans, then send a summary + done with the experiment link.
389+ final var playgroundParent = extractPlaygroundParent (request );
390+ if (playgroundParent .isEmpty ()) {
391+ handleExperimentSnapshot (
392+ os ,
393+ eval ,
394+ request ,
395+ braintrust ,
396+ apiClient ,
397+ projectId ,
398+ projectName ,
399+ projectUrl ,
400+ experimentName ,
401+ remoteScorers );
402+ return ;
403+ }
404+
384405 // Merge parameters: evaluator defaults + request overrides
385406 final Parameters mergedParameters =
386407 new Parameters (
@@ -391,7 +412,7 @@ private <I, O> void handleStreamingEval(
391412
392413 // Execute task and scorers for each case
393414 final Map <String , List <Double >> scoresByName = new ConcurrentHashMap <>();
394- final var parentInfo = extractParentInfo ( request );
415+ final var parentInfo = playgroundParent . get ( );
395416 final var braintrustParent = parentInfo .braintrustParent ();
396417 final var braintrustGeneration = parentInfo .generation ();
397418
@@ -547,13 +568,7 @@ private <I, O> void handleStreamingEval(
547568 }
548569
549570 sendSummaryEvent (
550- os ,
551- projectName ,
552- projectId ,
553- experimentName ,
554- projectUrl ,
555- experimentUrl ,
556- scoreSummaries );
571+ os , projectName , projectId , experimentName , projectUrl , scoreSummaries );
557572 sendDoneEvent (os );
558573 } catch (Exception e ) {
559574 // Send error event via SSE
@@ -577,6 +592,73 @@ private <I, O> void handleStreamingEval(
577592 }
578593 }
579594
595+ /**
596+ * Handles an experiment "snapshot" run: a remote eval triggered as an Experiment from the UI
597+ * (no playground parent). Rather than re-implementing experiment creation and span emission, it
598+ * builds a first-class {@link Eval} and runs it synchronously — so snapshots get the exact same
599+ * behavior as a normal {@code Eval.run()} (experiment creation with {@code ensure_new}, dataset
600+ * id/version linkage for Braintrust-backed datasets, standard span shape). When the run
601+ * completes it streams a single {@code summary} (with the created experiment's id/name/url) and
602+ * a {@code done} event.
603+ *
604+ * <p>Unlike playground runs, snapshots do not stream per-case {@code progress} events: the user
605+ * is handed the experiment link and views results in the experiment UI.
606+ */
607+ @ SuppressWarnings ({"unchecked" , "rawtypes" })
608+ private <I , O > void handleExperimentSnapshot (
609+ OutputStream os ,
610+ RemoteEval <I , O > eval ,
611+ EvalRequest request ,
612+ Braintrust braintrust ,
613+ BraintrustOpenApiClient apiClient ,
614+ String projectId ,
615+ String projectName ,
616+ String projectUrl ,
617+ String experimentName ,
618+ List <Scorer <I , O >> remoteScorers )
619+ throws IOException {
620+ // Combine local scorers (from the RemoteEval) with remote scorers (from the request).
621+ List <Scorer <I , O >> allScorers = new ArrayList <>(eval .getScorers ());
622+ allScorers .addAll (remoteScorers );
623+
624+ // TODO: when async evals are supported, simply begin the eval and hand back the link to the
625+ // user and finish eval in the background
626+
627+ var evalResult =
628+ Eval .<I , O >builder ()
629+ .name (experimentName )
630+ .config (braintrust .config ())
631+ .apiClient (apiClient )
632+ .projectId (projectId )
633+ .dataset ((Dataset <I , O >) extractDataset (request , apiClient ))
634+ .task (eval .getTask ())
635+ .scorers (allScorers .toArray (new Scorer [0 ]))
636+ .parameters (eval .getParameters ())
637+ .parameterValues (
638+ request .getParameters () == null
639+ ? Map .of ()
640+ : request .getParameters ())
641+ // Each snapshot run should produce a distinct experiment even if a prior
642+ // run used the same name (the backend dedupes the name on conflict).
643+ .ensureNew (true )
644+ .build ()
645+ .run ();
646+
647+ // Snapshots don't stream per-scorer progress. The scores are recorded on the experiment
648+ // and visible via the experiment link.
649+ sendExperimentSnapshotSummaryEvent (
650+ os ,
651+ projectName ,
652+ projectId ,
653+ evalResult .getExperimentId (),
654+ evalResult .getExperimentName () != null
655+ ? evalResult .getExperimentName ()
656+ : experimentName ,
657+ projectUrl ,
658+ evalResult .getExperimentUrl ());
659+ sendExperimentSnapshotDoneEvent (os );
660+ }
661+
580662 private void setEvalSpanAttributes (
581663 Span evalSpan ,
582664 BraintrustUtils .Parent braintrustParent ,
@@ -802,7 +884,6 @@ private void sendSummaryEvent(
802884 String projectId ,
803885 String experimentName ,
804886 String projectUrl ,
805- String experimentUrl ,
806887 Map <String , EvalResponse .ScoreSummary > scoreSummaries )
807888 throws IOException {
808889 Map <String , Object > summary = new LinkedHashMap <>();
@@ -835,6 +916,40 @@ private void sendDoneEvent(OutputStream os) throws IOException {
835916 sendSSEEvent (os , "done" , "" );
836917 }
837918
919+ /**
920+ * Sends the {@code summary} event for an experiment snapshot run. Unlike {@link
921+ * #sendSummaryEvent} (playground), this references the created experiment via {@code
922+ * experimentId}/{@code experimentUrl} so the UI can link straight to it, and carries no
923+ * streamed per-scorer scores (results live on the experiment itself).
924+ */
925+ private void sendExperimentSnapshotSummaryEvent (
926+ OutputStream os ,
927+ String projectName ,
928+ String projectId ,
929+ @ Nullable String experimentId ,
930+ String experimentName ,
931+ String projectUrl ,
932+ @ Nullable String experimentUrl )
933+ throws IOException {
934+ Map <String , Object > summary = new LinkedHashMap <>();
935+ summary .put ("projectName" , projectName );
936+ summary .put ("projectId" , projectId );
937+ summary .put ("experimentId" , experimentId );
938+ summary .put ("experimentName" , experimentName );
939+ summary .put ("projectUrl" , projectUrl );
940+ summary .put ("experimentUrl" , experimentUrl );
941+ summary .put ("comparisonExperimentName" , null );
942+ summary .put ("scores" , Map .of ());
943+ summary .put ("metrics" , Map .of ());
944+
945+ sendSSEEvent (os , "summary" , toJson (summary ));
946+ }
947+
948+ /** Sends the terminating {@code done} event for an experiment snapshot run. */
949+ private void sendExperimentSnapshotDoneEvent (OutputStream os ) throws IOException {
950+ sendSSEEvent (os , "done" , "" );
951+ }
952+
838953 private void sendResponse (
839954 HttpExchange exchange , int statusCode , String contentType , String body )
840955 throws IOException {
@@ -1105,44 +1220,50 @@ private record ParentInfo(
11051220 @ Nonnull BraintrustUtils .Parent braintrustParent , @ Nullable String generation ) {}
11061221
11071222 /**
1108- * Extracts parent information from the eval request.
1223+ * Extracts the playground parent (and generation) from the eval request, if present.
1224+ *
1225+ * <p>Playground runs send a {@code parent} object carrying {@code object_type}/{@code
1226+ * object_id}; experiment ("snapshot") runs send no such parent (instead {@code experiment_name}
1227+ * + project via headers) and are handled by {@link #handleExperimentSnapshot}. Returns empty
1228+ * for the latter case.
11091229 *
11101230 * @param request The eval request
1111- * @return ParentInfo containing braintrustParent and generation
1231+ * @return the playground {@link ParentInfo} if this is a playground run, else empty
11121232 */
1113- private static ParentInfo extractParentInfo (EvalRequest request ) {
1114- String parentSpec = null ;
1115- String generation = null ;
1233+ private static Optional <ParentInfo > extractPlaygroundParent (EvalRequest request ) {
1234+ if (!(request .getParent () instanceof Map )) {
1235+ return Optional .empty ();
1236+ }
1237+ @ SuppressWarnings ("unchecked" )
1238+ Map <String , Object > parentMap = (Map <String , Object >) request .getParent ();
1239+ String objectType = (String ) parentMap .get ("object_type" );
1240+ String objectId = (String ) parentMap .get ("object_id" );
1241+ if (objectType == null && objectId == null ) {
1242+ return Optional .empty ();
1243+ }
11161244
1117- // Extract parent spec and generation from request
1118- if (request .getParent () != null && request .getParent () instanceof Map ) {
1119- @ SuppressWarnings ("unchecked" )
1120- Map <String , Object > parentMap = (Map <String , Object >) request .getParent ();
1121- String objectType = (String ) parentMap .get ("object_type" );
1122- String objectId = (String ) parentMap .get ("object_id" );
1245+ if (objectType == null || objectId == null ) {
1246+ throw new IllegalArgumentException (
1247+ "malformed braintrust parent: %s, %s" .formatted (objectType , objectId ));
1248+ }
11231249
1124- // Extract generation from propagated_event.span_attributes.generation
1125- Object propEventObj = parentMap .get ("propagated_event" );
1126- if (propEventObj instanceof Map ) {
1250+ // Extract generation from propagated_event.span_attributes.generation
1251+ String generation = null ;
1252+ Object propEventObj = parentMap .get ("propagated_event" );
1253+ if (propEventObj instanceof Map ) {
1254+ @ SuppressWarnings ("unchecked" )
1255+ Map <String , Object > propEvent = (Map <String , Object >) propEventObj ;
1256+ Object spanAttrsObj = propEvent .get ("span_attributes" );
1257+ if (spanAttrsObj instanceof Map ) {
11271258 @ SuppressWarnings ("unchecked" )
1128- Map <String , Object > propEvent = (Map <String , Object >) propEventObj ;
1129- Object spanAttrsObj = propEvent .get ("span_attributes" );
1130- if (spanAttrsObj instanceof Map ) {
1131- @ SuppressWarnings ("unchecked" )
1132- Map <String , Object > spanAttrs = (Map <String , Object >) spanAttrsObj ;
1133- generation = (String ) spanAttrs .get ("generation" );
1134- }
1135- }
1136-
1137- if (objectType != null && objectId != null ) {
1138- parentSpec = "playground_id:" + objectId ;
1259+ Map <String , Object > spanAttrs = (Map <String , Object >) spanAttrsObj ;
1260+ generation = (String ) spanAttrs .get ("generation" );
11391261 }
11401262 }
11411263
1142- if (parentSpec == null ) {
1143- throw new IllegalArgumentException ("braintrust parent (playground_id) not found" );
1144- }
1145- return new ParentInfo (BraintrustUtils .parseParent (parentSpec ), generation );
1264+ return Optional .of (
1265+ new ParentInfo (
1266+ BraintrustUtils .parseParent ("playground_id:" + objectId ), generation ));
11461267 }
11471268
11481269 /**
0 commit comments