1use aether_core::events::{AgentEvent, MessageEvent, ToolEvent, TurnEvent, TurnOutcome};
2use futures::StreamExt;
3use llm::{ChatMessage, Context, LlmResponse, StreamingModelProvider};
4use schemars::{JsonSchema, Schema, schema_for};
5use serde::{Deserialize, Serialize};
6use std::borrow::Borrow;
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt::Write as _;
9use thiserror::Error;
10
11const TRANSCRIPT_PAYLOAD_CHARS: usize = 2_000;
12
13pub fn judge() -> JudgeBuilder {
15 JudgeBuilder::default()
16}
17
18#[derive(Debug, Clone)]
22pub struct Judge {
23 pub prompt: String,
24 pub criteria: Vec<JudgeCriterionSpec>,
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct JudgeBuilder {
29 instructions: Option<String>,
30 task: Option<String>,
31 context: JudgeContext,
32 criteria: Vec<JudgeCriterionSpec>,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct JudgeContext {
38 pub transcript: Option<Vec<AgentEvent>>,
39 pub diff: Option<String>,
40 pub files: BTreeMap<String, String>,
41}
42
43#[derive(Debug, Clone, JsonSchema)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct JudgeCriterionSpec {
47 pub id: String,
48 pub description: String,
49 #[serde(default = "default_blocking")]
50 pub blocking: bool,
51 #[serde(default = "default_weight")]
52 pub weight: f64,
53 #[serde(default = "default_threshold")]
54 pub threshold: f64,
55}
56
57#[derive(Debug, Clone, Serialize, JsonSchema)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct JudgeSummary {
61 pub passed: bool,
62 pub score: f64,
63 pub reason: String,
64 pub criteria: Vec<JudgeCriterionSummary>,
65}
66
67#[derive(Debug, Clone, Serialize, JsonSchema)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct JudgeCriterionSummary {
70 pub id: String,
71 pub description: String,
72 pub blocking: bool,
73 pub weight: f64,
74 pub threshold: f64,
75 pub score: f64,
76 pub passed: bool,
77 pub reason: String,
78}
79
80#[derive(Debug, Deserialize, JsonSchema)]
82#[serde(deny_unknown_fields)]
83pub struct JudgeRubricResponse {
84 pub criteria: Vec<JudgeCriterionResponse>,
85 pub overall_reason: String,
86}
87
88#[derive(Debug, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct JudgeCriterionResponse {
91 pub id: String,
92 pub score: f64,
93 pub reason: String,
94}
95
96#[derive(Debug, Error)]
97pub enum JudgeError {
98 #[error("invalid judge input: {0}")]
99 InvalidInput(String),
100
101 #[error("judge LLM stream error: {0}")]
102 Stream(#[from] llm::LlmError),
103
104 #[error("judge returned invalid JSON: {source}\nRaw response: {raw_response}")]
105 InvalidJson {
106 #[source]
107 source: serde_json::Error,
108 raw_response: String,
109 },
110
111 #[error("judge returned invalid judgment: {0}")]
112 InvalidJudgment(String),
113}
114
115impl Judge {
116 pub fn response_schema() -> Schema {
117 JudgeRubricResponse::schema()
118 }
119
120 pub async fn run(&self, llm: &dyn StreamingModelProvider) -> Result<JudgeSummary, JudgeError> {
123 tracing::info!("Running LLM judge");
124 let raw_response = self.stream_response(llm).await?;
125 let response: JudgeRubricResponse = serde_json::from_str(extract_json_object(&raw_response))
126 .map_err(|source| JudgeError::InvalidJson { source, raw_response: raw_response.clone() })?;
127 self.summarize(response)
128 }
129
130 pub fn summarize(&self, response: JudgeRubricResponse) -> Result<JudgeSummary, JudgeError> {
131 let mut responses = BTreeMap::new();
132 for criterion in response.criteria {
133 let id = criterion.id.clone();
134 if responses.insert(id.clone(), criterion).is_some() {
135 return Err(JudgeError::InvalidJudgment(format!("duplicate response criterion id `{id}`")));
136 }
137 }
138
139 let mut summaries = Vec::with_capacity(self.criteria.len());
140 let mut weighted_score = 0.0;
141 let mut total_weight = 0.0;
142 let mut blocking_failed = false;
143
144 for criterion in &self.criteria {
145 let Some(response) = responses.remove(&criterion.id) else {
146 return Err(JudgeError::InvalidJudgment(format!("missing response criterion `{}`", criterion.id)));
147 };
148 if !response.score.is_finite() || !(0.0..=1.0).contains(&response.score) {
149 return Err(JudgeError::InvalidJudgment(format!(
150 "criterion `{}` score must be between 0.0 and 1.0",
151 criterion.id
152 )));
153 }
154
155 let passed = response.score >= criterion.threshold;
156 blocking_failed |= criterion.blocking && !passed;
157 weighted_score += response.score * criterion.weight;
158 total_weight += criterion.weight;
159 summaries.push(JudgeCriterionSummary {
160 id: criterion.id.clone(),
161 description: criterion.description.clone(),
162 blocking: criterion.blocking,
163 weight: criterion.weight,
164 threshold: criterion.threshold,
165 score: response.score,
166 passed,
167 reason: response.reason,
168 });
169 }
170
171 if let Some(id) = responses.keys().next() {
172 return Err(JudgeError::InvalidJudgment(format!("unknown response criterion `{id}`")));
173 }
174
175 let weighted_score = weighted_score / total_weight;
176 let score = if blocking_failed { 0.0 } else { weighted_score };
177 let reason = if blocking_failed {
178 format!("weighted score {:.2}; one or more blockers failed; {}", weighted_score, response.overall_reason)
179 } else {
180 format!("weighted score {:.2}; all blockers met; {}", weighted_score, response.overall_reason)
181 };
182
183 Ok(JudgeSummary { passed: !blocking_failed, score, reason, criteria: summaries })
184 }
185
186 async fn stream_response(&self, llm: &dyn StreamingModelProvider) -> Result<String, JudgeError> {
187 let message = ChatMessage::user(self.prompt.clone());
188 let mut response_stream = llm.stream_response(&Context::new(vec![message], vec![]));
189 let mut raw_response = String::new();
190 while let Some(result) = response_stream.next().await {
191 match result {
192 Ok(LlmResponse::Text { chunk }) => raw_response.push_str(&chunk),
193 Err(error) => return Err(JudgeError::Stream(error)),
194 _ => {}
195 }
196 }
197 Ok(raw_response)
198 }
199}
200
201impl JudgeBuilder {
202 pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
203 self.instructions = Some(instructions.into());
204 self
205 }
206
207 pub fn task(mut self, task: impl Into<String>) -> Self {
208 self.task = Some(task.into());
209 self
210 }
211
212 pub fn transcript(mut self, transcript: impl Into<Vec<AgentEvent>>) -> Self {
213 self.context.transcript = Some(transcript.into());
214 self
215 }
216
217 pub fn diff(mut self, diff: impl Into<String>) -> Self {
218 self.context.diff = Some(diff.into());
219 self
220 }
221
222 pub fn file(mut self, path: impl Into<String>, contents: impl Into<String>) -> Self {
223 self.context.files.insert(path.into(), contents.into());
224 self
225 }
226
227 pub fn files<T, U, V>(mut self, files: T) -> Self
228 where
229 T: IntoIterator<Item = (U, V)>,
230 U: Into<String>,
231 V: Into<String>,
232 {
233 self.context.files.extend(files.into_iter().map(|(path, contents)| (path.into(), contents.into())));
234 self
235 }
236
237 pub fn criteria<T, U>(mut self, criteria: T) -> Self
238 where
239 T: IntoIterator<Item = U>,
240 U: Borrow<JudgeCriterionSpec>,
241 {
242 self.criteria = criteria.into_iter().map(|criterion| criterion.borrow().clone()).collect();
243 self
244 }
245
246 pub fn context(mut self, context: JudgeContext) -> Self {
247 self.context = context;
248 self
249 }
250
251 pub fn build(self) -> Result<Judge, JudgeError> {
252 let task = self.task.ok_or_else(|| JudgeError::InvalidInput("judge task must be provided".to_string()))?;
253 let criteria = normalize_criteria(self.criteria)?;
254 let prompt = build_prompt(&self.instructions.unwrap_or_default(), &task, &self.context, &criteria);
255 Ok(Judge { prompt, criteria })
256 }
257}
258
259impl JudgeCriterionSpec {
260 pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
261 Self {
262 id: id.into(),
263 description: description.into(),
264 blocking: default_blocking(),
265 weight: default_weight(),
266 threshold: default_threshold(),
267 }
268 }
269
270 pub fn blocking(mut self, blocking: bool) -> Self {
271 self.blocking = blocking;
272 self
273 }
274
275 pub fn weight(mut self, weight: f64) -> Self {
276 self.weight = weight;
277 self
278 }
279
280 pub fn threshold(mut self, threshold: f64) -> Self {
281 self.threshold = threshold;
282 self
283 }
284}
285
286impl JudgeSummary {
287 pub fn blocking_failures(&self) -> impl Iterator<Item = String> + '_ {
289 self.criteria
290 .iter()
291 .filter(|criterion| criterion.blocking && !criterion.passed)
292 .map(|criterion| format!("judge criterion `{}`: {}", criterion.id, criterion.reason))
293 }
294}
295
296impl JudgeRubricResponse {
297 pub fn schema() -> Schema {
298 schema_for!(Self)
299 }
300}
301
302fn build_prompt(instructions: &str, task: &str, context: &JudgeContext, criteria: &[JudgeCriterionSpec]) -> String {
303 let mut sections = vec![
304 format!("## Instructions\n\n{instructions}"),
305 format!("## Task\n\nThe agent you're evaluating was given this task: <task>{task}</task>"),
306 ];
307
308 if let Some(transcript) = &context.transcript
309 && !transcript.is_empty()
310 {
311 sections.push(format!(
312 "## Agent Transcript\n\nTranscript of the agent you're evaluating: <transcript>{}</transcript>",
313 format_transcript(transcript)
314 ));
315 }
316
317 if let Some(diff) = &context.diff
318 && !diff.is_empty()
319 {
320 sections.push(format!("## Git diff\n\nGit diff produced by the agent you're evaluating: <diff>{diff}</diff>"));
321 }
322
323 if !context.files.is_empty() {
324 let blocks = context
325 .files
326 .iter()
327 .map(|(path, contents)| format!("<file><path>{path}</path><contents>{contents}</contents></file>"))
328 .collect::<Vec<_>>()
329 .join("\n");
330 sections.push(format!("## File Contents\n\nFiles under evaluation: <files>{blocks}</files>"));
331 }
332
333 let rubric = criteria
334 .iter()
335 .map(|criterion| {
336 format!(
337 "- id: {}\n blocking: {}\n weight: {}\n threshold: {}\n description: {}",
338 criterion.id, criterion.blocking, criterion.weight, criterion.threshold, criterion.description
339 )
340 })
341 .collect::<Vec<_>>()
342 .join("\n");
343 sections.push(format!("## Rubric criteria\n\n{rubric}"));
344 sections.push(format!(
345 "{}\n{}\n{}\n{}",
346 "Return exactly one result for every criterion ID above and no extra criteria.",
347 "Scores must be normalized numbers from 0.0 to 1.0.",
348 "Respond with ONLY a JSON object matching this schema:",
349 judge_response_schema()
350 ));
351
352 sections.join("\n\n")
353}
354
355fn normalize_criteria(criteria: Vec<JudgeCriterionSpec>) -> Result<Vec<JudgeCriterionSpec>, JudgeError> {
356 if criteria.is_empty() {
357 return Err(JudgeError::InvalidInput("judge criteria must not be empty".to_string()));
358 }
359
360 let mut ids = BTreeSet::new();
361 let mut normalized = Vec::with_capacity(criteria.len());
362 for mut criterion in criteria {
363 criterion.id = criterion.id.trim().to_string();
364 if criterion.id.is_empty() {
365 return Err(JudgeError::InvalidInput("judge criterion id must not be empty".to_string()));
366 }
367 if !ids.insert(criterion.id.clone()) {
368 return Err(JudgeError::InvalidInput(format!("duplicate judge criterion id `{}`", criterion.id)));
369 }
370 if criterion.description.trim().is_empty() {
371 return Err(JudgeError::InvalidInput(format!(
372 "judge criterion `{}` description must not be empty",
373 criterion.id
374 )));
375 }
376 if !criterion.weight.is_finite() || criterion.weight <= 0.0 {
377 return Err(JudgeError::InvalidInput(format!(
378 "judge criterion `{}` weight must be positive and finite",
379 criterion.id
380 )));
381 }
382 if !criterion.threshold.is_finite() || !(0.0..=1.0).contains(&criterion.threshold) {
383 return Err(JudgeError::InvalidInput(format!(
384 "judge criterion `{}` threshold must be between 0.0 and 1.0",
385 criterion.id
386 )));
387 }
388 normalized.push(criterion);
389 }
390 Ok(normalized)
391}
392
393fn extract_json_object(response: &str) -> &str {
394 let trimmed = response.trim();
395 match (trimmed.find('{'), trimmed.rfind('}')) {
396 (Some(start), Some(end)) if start <= end => &trimmed[start..=end],
397 _ => trimmed,
398 }
399}
400
401fn judge_response_schema() -> String {
402 serde_json::to_string_pretty(&JudgeRubricResponse::schema()).unwrap()
403}
404
405fn default_blocking() -> bool {
406 true
407}
408
409fn default_weight() -> f64 {
410 1.0
411}
412
413fn default_threshold() -> f64 {
414 1.0
415}
416
417fn format_transcript(messages: &[AgentEvent]) -> String {
418 let mut transcript = String::new();
419 for message in messages {
420 if let Some(line) = get_transcript_line(message, TRANSCRIPT_PAYLOAD_CHARS) {
421 let _ = writeln!(transcript, "{line}");
422 }
423 }
424 transcript
425}
426
427fn get_transcript_line(message: &AgentEvent, max_payload_chars: usize) -> Option<String> {
428 match message {
429 AgentEvent::Message(MessageEvent::Text { chunk, is_complete: true, .. }) if !chunk.is_empty() => {
430 Some(format!("[agent] {}", truncate_chars(chunk, max_payload_chars)))
431 }
432 AgentEvent::Tool(ToolEvent::Call { request, .. }) => Some(format!(
433 "[tool-call] {} arguments={}",
434 request.name,
435 truncate_chars(&request.arguments, max_payload_chars)
436 )),
437 AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
438 Some(format!("[tool-result] {}: {}", result.name, truncate_chars(&result.result, max_payload_chars)))
439 }
440 AgentEvent::Tool(ToolEvent::Error { error, .. }) => {
441 Some(format!("[tool-error] {}: {}", error.name, truncate_chars(&error.error, max_payload_chars)))
442 }
443 AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Failed { error, .. } }) => {
444 Some(format!("[error] {}", truncate_chars(error, max_payload_chars)))
445 }
446 AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Cancelled }) => Some("[cancelled]".to_string()),
447 AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Completed }) => Some("[done]".to_string()),
448 _ => None,
449 }
450}
451
452fn truncate_chars(value: &str, max_chars: usize) -> String {
453 if value.chars().count() <= max_chars {
454 return value.to_string();
455 }
456
457 let truncated: String = value.chars().take(max_chars).collect();
458 format!("{truncated}... [truncated]")
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use aether_core::events::{AgentEvent, StreamState, TurnOutcome};
465 use llm::testing::FakeLlmProvider;
466 use llm::{LlmError, ProviderError};
467
468 const VALID_RESPONSE: &str = r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"correct"},{"id":"clarity","score":0.5,"reason":"brief"}],"overall_reason":"good"}"#;
469
470 #[test]
471 fn transcript_lines_label_each_message_kind() {
472 let call = AgentEvent::tool_call("call_1", "bash", "{}");
473
474 assert_eq!(
475 get_transcript_line(&AgentEvent::text("msg_1", "hi", StreamState::Complete), 100).unwrap(),
476 "[agent] hi"
477 );
478 assert_eq!(get_transcript_line(&call, 100).unwrap(), "[tool-call] bash arguments={}");
479 assert_eq!(get_transcript_line(&AgentEvent::turn_ended(TurnOutcome::Completed), 100).unwrap(), "[done]");
480 }
481
482 #[test]
483 fn transcript_lines_truncate_long_payloads() {
484 let line = get_transcript_line(&AgentEvent::text("msg_1", &"a".repeat(50), StreamState::Complete), 10).unwrap();
485
486 assert_eq!(line, format!("[agent] {}... [truncated]", "a".repeat(10)));
487 }
488
489 #[test]
490 fn tool_result_transcript_uses_result_arguments() {
491 let message = AgentEvent::tool_result("call_1", "coding__read_file", r#"["Cargo.toml"]"#, "file contents");
492
493 assert_eq!(get_transcript_line(&message, 100).unwrap(), "[tool-result] coding__read_file: file contents");
494 }
495
496 #[test]
497 fn judge_builder_builds_prompt_from_context_and_criteria() {
498 let judge = judge()
499 .instructions("be strict")
500 .task("do the thing")
501 .diff("+added line")
502 .file("notes.txt", "beta\n")
503 .criteria([criterion("works", "the task works", true, 2.0, 0.9)])
504 .build()
505 .unwrap();
506
507 assert!(judge.prompt.contains("## Instructions\n\nbe strict"));
508 assert!(judge.prompt.contains("## Task"));
509 assert!(judge.prompt.contains("The agent you're evaluating was given this task: <task>do the thing</task>"));
510 assert!(judge.prompt.contains("## Git diff"));
511 assert!(judge.prompt.contains("Git diff produced by the agent you're evaluating: <diff>+added line</diff>"));
512 assert!(judge.prompt.contains("## File Contents"));
513 assert!(judge.prompt.contains("<path>notes.txt</path>"));
514 assert!(judge.prompt.contains("<contents>beta\n</contents>"));
515 assert!(judge.prompt.contains("## Rubric criteria"));
516 assert!(judge.prompt.contains("blocking: true"));
517 assert!(judge.prompt.contains("threshold: 0.9"));
518 assert!(judge.prompt.contains("weight: 2"));
519 assert!(judge.prompt.contains("Return exactly one result for every criterion ID above and no extra criteria."));
520 assert!(judge.prompt.contains("Respond with ONLY a JSON object matching this schema:"));
521 }
522
523 #[test]
524 fn judge_builder_renders_transcript_context() {
525 let messages = vec![
526 AgentEvent::tool_call("call_1", "bash", "{}"),
527 AgentEvent::text("msg_1", "all done", StreamState::Complete),
528 ];
529
530 let judge = judge()
531 .task("edit the file")
532 .transcript(messages)
533 .criteria([criterion("behavior", "did it work", true, 1.0, 1.0)])
534 .build()
535 .unwrap();
536
537 assert!(judge.prompt.contains("## Agent Transcript"));
538 assert!(judge.prompt.contains("[tool-call] bash"));
539 assert!(judge.prompt.contains("[agent] all done"));
540 }
541
542 #[test]
543 fn judge_builder_accepts_slice_criteria() {
544 let criteria = vec![criterion("behavior", "does the thing", true, 1.0, 0.8)];
545 let judge = judge().task("do it").criteria(&criteria).build().unwrap();
546 assert_eq!(judge.criteria[0].id, "behavior");
547 }
548
549 #[test]
550 fn judge_summarizes_weighted_rubric() {
551 let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
552
553 let summary = judge.summarize(serde_json::from_str(VALID_RESPONSE).unwrap()).unwrap();
554
555 assert!(summary.passed);
556 assert!((summary.score - 0.875).abs() < f64::EPSILON);
557 assert!((summary.criteria[1].score - 0.5).abs() < f64::EPSILON);
558 assert!(summary.reason.contains("all blockers met"));
559 }
560
561 #[test]
562 fn judge_zeroes_score_when_blocker_fails() {
563 let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
564 let response = serde_json::from_str(
565 r#"{"criteria":[{"id":"behavior","score":0.75,"reason":"wrong behavior"},{"id":"clarity","score":1.0,"reason":"clear"}],"overall_reason":"bad"}"#,
566 )
567 .unwrap();
568
569 let summary = judge.summarize(response).unwrap();
570
571 assert!(!summary.passed);
572 assert!(summary.score.abs() < f64::EPSILON);
573 assert!(!summary.criteria[0].passed);
574 assert!(summary.reason.contains("one or more blockers failed"));
575 }
576
577 #[test]
578 fn judge_rejects_invalid_criterion_sets() {
579 let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
580 for raw_response in [
581 r#"{"criteria":[],"overall_reason":"missing"}"#,
582 r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"ok"},{"id":"behavior","score":1.0,"reason":"ok"}],"overall_reason":"duplicate"}"#,
583 r#"{"criteria":[{"id":"behavior","score":1.0,"reason":"ok"},{"id":"clarity","score":1.0,"reason":"ok"},{"id":"extra","score":1.0,"reason":"ok"}],"overall_reason":"unknown"}"#,
584 r#"{"criteria":[{"id":"behavior","score":1.5,"reason":"bad"},{"id":"clarity","score":1.0,"reason":"ok"}],"overall_reason":"score"}"#,
585 ] {
586 let response = serde_json::from_str(raw_response).unwrap();
587
588 let error = judge.summarize(response).unwrap_err();
589
590 assert!(matches!(error, JudgeError::InvalidJudgment(_)), "response: {raw_response}");
591 }
592 }
593
594 #[test]
595 fn judge_builder_rejects_invalid_inputs() {
596 for (builder, expected) in [
597 (judge().criteria([criterion("behavior", "ok", true, 1.0, 0.8)]), "judge task must be provided"),
598 (judge().task("prompt"), "judge criteria must not be empty"),
599 (
600 judge().task("prompt").criteria([criterion("", "ok", true, 1.0, 0.8)]),
601 "judge criterion id must not be empty",
602 ),
603 (
604 judge().task("prompt").criteria([criterion("behavior", "ok", true, 0.0, 0.8)]),
605 "weight must be positive and finite",
606 ),
607 ] {
608 let error = builder.build().unwrap_err();
609 assert!(error.to_string().contains(expected), "got: {error}");
610 }
611 }
612
613 #[test]
614 fn blocking_failures_report_only_blocking_criteria_below_threshold() {
615 let criterion = |id: &str, blocking, score: f64| JudgeCriterionSummary {
616 id: id.to_string(),
617 description: "desc".to_string(),
618 blocking,
619 weight: 1.0,
620 threshold: 0.8,
621 score,
622 passed: score >= 0.8,
623 reason: format!("{id} reason"),
624 };
625 let summary = JudgeSummary {
626 passed: false,
627 score: 0.0,
628 reason: "r".to_string(),
629 criteria: vec![
630 criterion("met", true, 0.9),
631 criterion("failed", true, 0.5),
632 criterion("advisory", false, 0.0),
633 ],
634 };
635
636 let failures: Vec<String> = summary.blocking_failures().collect();
637
638 assert_eq!(failures, vec!["judge criterion `failed`: failed reason".to_string()]);
639 }
640
641 #[tokio::test]
642 async fn judge_run_extracts_json_object_from_surrounding_prose() {
643 let response = format!("Here is my assessment:\n{VALID_RESPONSE}");
644 let judge_llm = FakeLlmProvider::with_single_response(vec![LlmResponse::text(&response)]);
645 let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
646
647 let summary = judge.run(&judge_llm).await.unwrap();
648
649 assert!(summary.passed);
650 }
651
652 #[tokio::test]
653 async fn judge_run_returns_invalid_json_error_with_raw_response() {
654 let judge_llm = FakeLlmProvider::with_single_response(vec![LlmResponse::text("not json")]);
655 let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
656
657 let error = judge.run(&judge_llm).await.unwrap_err();
658
659 let JudgeError::InvalidJson { raw_response, .. } = error else {
660 panic!("expected InvalidJson, got {error:?}");
661 };
662 assert_eq!(raw_response, "not json");
663 }
664
665 #[tokio::test]
666 async fn judge_run_returns_stream_error_on_llm_failure() {
667 let judge_llm =
668 FakeLlmProvider::from_results(vec![vec![Err(LlmError::from(ProviderError::api("boom".to_string())))]]);
669 let judge = judge().task("prompt").criteria(default_criteria()).build().unwrap();
670
671 let error = judge.run(&judge_llm).await.unwrap_err();
672
673 assert!(matches!(error, JudgeError::Stream(_)));
674 assert!(error.to_string().contains("boom"));
675 }
676
677 fn criterion(id: &str, description: &str, blocking: bool, weight: f64, threshold: f64) -> JudgeCriterionSpec {
678 JudgeCriterionSpec { id: id.to_string(), description: description.to_string(), blocking, weight, threshold }
679 }
680
681 fn default_criteria() -> Vec<JudgeCriterionSpec> {
682 vec![
683 criterion("behavior", "The behavior is correct.", true, 3.0, 1.0),
684 criterion("clarity", "The response is clear.", false, 1.0, 0.5),
685 ]
686 }
687}