1use std::sync::Arc;
4
5use serde::Deserialize;
6
7use agent_sdk_core::{
8 AgentError, EntityKind, EntityRef, PrivacyClass, ProviderAdapter, ProviderMessage,
9 ProviderMessageRole, ProviderRequest, RunId, RunTrace, SessionTimeline, TurnTrace,
10 domain::EntityId,
11};
12use agent_sdk_eval::{
13 ComparisonDesign, EvaluationBudget, EvaluationConfidence, EvaluationId, EvaluationReport,
14 EvaluationRequest, EvaluationSubject, EvaluationSubjectRole, EvaluationUsage,
15 EvaluationVerdict, Evaluator, EvaluatorJudgment, EvidenceBundle, EvidenceRole, ExpectedOutcome,
16 TraceMetrics, TraceMetricsComparison,
17};
18
19#[derive(Clone, Debug)]
21pub struct AgentTraceEvaluation {
22 request: EvaluationRequest,
23 evidence: EvidenceBundle,
24 metrics: TraceMetrics,
25 metrics_comparison: Option<TraceMetricsComparison>,
26}
27
28impl AgentTraceEvaluation {
29 pub fn turn(trace: &TurnTrace, expected_outcome: ExpectedOutcome) -> Result<Self, AgentError> {
31 let evidence = EvidenceBundle::from_turn_trace(trace)?;
32 let metrics = TraceMetrics::from_turn_trace(trace)?;
33 let turn_id = trace.turn_id.clone().ok_or_else(|| {
34 AgentError::contract_violation("turn trace is missing turn id for evaluation")
35 })?;
36 let request = EvaluationRequest::new(
37 EvaluationId::new(format!("evaluation.turn.{}", turn_id.as_str())),
38 evidence.scope.clone(),
39 expected_outcome,
40 )
41 .with_subject(agent_trace_subject(&evidence)?);
42 Ok(Self {
43 request,
44 evidence,
45 metrics,
46 metrics_comparison: None,
47 })
48 }
49
50 pub fn run(trace: &RunTrace, expected_outcome: ExpectedOutcome) -> Result<Self, AgentError> {
52 let evidence = EvidenceBundle::from_run_trace(trace)?;
53 let metrics = TraceMetrics::from_run_trace(trace)?;
54 let run_id = trace.run_id.clone().ok_or_else(|| {
55 AgentError::contract_violation("run trace is missing run id for evaluation")
56 })?;
57 let request = EvaluationRequest::new(
58 EvaluationId::new(format!("evaluation.run.{}", run_id.as_str())),
59 evidence.scope.clone(),
60 expected_outcome,
61 )
62 .with_subject(agent_trace_subject(&evidence)?);
63 Ok(Self {
64 request,
65 evidence,
66 metrics,
67 metrics_comparison: None,
68 })
69 }
70
71 pub fn session(
73 timeline: &SessionTimeline,
74 expected_outcome: ExpectedOutcome,
75 ) -> Result<Self, AgentError> {
76 let evidence = EvidenceBundle::from_session_timeline(timeline)?;
77 let metrics = TraceMetrics::from_session_timeline(timeline);
78 let request = EvaluationRequest::new(
79 EvaluationId::new(format!(
80 "evaluation.session.{}",
81 timeline.session_id.as_str()
82 )),
83 evidence.scope.clone(),
84 expected_outcome,
85 )
86 .with_subject(agent_trace_subject(&evidence)?);
87 Ok(Self {
88 request,
89 evidence,
90 metrics,
91 metrics_comparison: None,
92 })
93 }
94
95 pub fn compare_sessions(
97 observed: &SessionTimeline,
98 baseline: &SessionTimeline,
99 expected_outcome: ExpectedOutcome,
100 ) -> Result<Self, AgentError> {
101 let mut evidence = EvidenceBundle::from_session_timeline(observed)?;
102 let baseline_evidence = EvidenceBundle::from_session_timeline(baseline)?;
103 let baseline_outcome_ref = baseline_evidence.outcome_ref.clone();
104 let comparison_metrics = TraceMetricsComparison::sessions(observed, baseline);
105 for mut item in baseline_evidence.items {
106 item.role = EvidenceRole::Baseline;
107 evidence = evidence.with_item(item);
108 }
109 let mut request = EvaluationRequest::new(
110 EvaluationId::new(format!(
111 "evaluation.session.compare.{}.{}",
112 observed.session_id.as_str(),
113 baseline.session_id.as_str()
114 )),
115 evidence.scope.clone(),
116 expected_outcome,
117 )
118 .with_subject(agent_trace_subject(&evidence)?)
119 .with_comparison(ComparisonDesign::PairedScopes {
120 observed_scope: comparison_metrics.observed.scope.clone(),
121 comparison_scope: comparison_metrics.baseline.scope.clone(),
122 })
123 .with_metric_deltas(comparison_metrics.metric_deltas.clone());
124
125 if let Some(subject_ref) = baseline_outcome_ref {
126 request = request.with_subject(EvaluationSubject {
127 subject_ref,
128 role: EvaluationSubjectRole::Baseline,
129 redacted_summary: Some("baseline session outcome".to_string()),
130 });
131 }
132
133 Ok(Self {
134 request,
135 evidence,
136 metrics: comparison_metrics.observed.clone(),
137 metrics_comparison: Some(comparison_metrics),
138 })
139 }
140
141 pub fn compare(mut self, comparison: ComparisonDesign) -> Self {
143 self.request.comparison = comparison;
144 self
145 }
146
147 pub fn with_budget(mut self, budget: EvaluationBudget) -> Self {
149 self.request.budget = budget;
150 self
151 }
152
153 pub fn evaluate<E: Evaluator>(&self, evaluator: &E) -> Result<EvaluationReport, AgentError> {
155 let report = evaluator.evaluate(&self.request, &self.evidence)?;
156 report.validate_confidence_contract_for_request(&self.request)?;
157 Ok(report)
158 }
159
160 pub fn request(&self) -> &EvaluationRequest {
162 &self.request
163 }
164
165 pub fn evidence(&self) -> &EvidenceBundle {
167 &self.evidence
168 }
169
170 pub fn metrics(&self) -> &TraceMetrics {
172 &self.metrics
173 }
174
175 pub fn metrics_comparison(&self) -> Option<&TraceMetricsComparison> {
177 self.metrics_comparison.as_ref()
178 }
179}
180
181pub struct AiTraceEvaluator {
184 provider: Arc<dyn ProviderAdapter>,
185}
186
187impl AiTraceEvaluator {
188 pub fn new(provider: Arc<dyn ProviderAdapter>) -> Self {
190 Self { provider }
191 }
192
193 pub fn from_provider<P>(provider: P) -> Self
195 where
196 P: ProviderAdapter + 'static,
197 {
198 Self::new(Arc::new(provider))
199 }
200}
201
202impl Evaluator for AiTraceEvaluator {
203 fn evaluate(
204 &self,
205 request: &EvaluationRequest,
206 evidence: &EvidenceBundle,
207 ) -> Result<EvaluationReport, AgentError> {
208 request.budget.require_provider_call()?;
209 let prompt = ai_evaluator_prompt(request, evidence)?;
210 let provider_request = ProviderRequest {
211 schema_version: ProviderRequest::SCHEMA_VERSION,
212 projection_policy_ref: "policy.evaluation.content_refs_only".to_string(),
213 messages: vec![ProviderMessage {
214 role: ProviderMessageRole::Developer,
215 content: prompt,
216 privacy: PrivacyClass::ContentRefsOnly,
217 projected_metadata: None,
218 }],
219 projection_item_count: evidence.items.len(),
220 structured_output_hint: None,
221 };
222 let response = self.provider.complete(&provider_request)?;
223 let usage = EvaluationUsage {
224 provider_calls: 1,
225 provider_usage: Some(self.provider.extract_usage(&response)),
226 };
227 report_from_provider_response(request, evidence, &response.output_text, usage)
228 }
229}
230
231#[derive(Deserialize)]
232struct AiEvaluatorReply {
233 #[serde(default)]
234 verdict: Option<EvaluationVerdict>,
235 #[serde(default)]
236 score: Option<String>,
237 #[serde(default)]
238 confidence: Option<EvaluationConfidence>,
239 #[serde(default)]
240 redacted_summary: Option<String>,
241 #[serde(default)]
242 support_refs: Vec<WireEntityRef>,
243 #[serde(default)]
244 limitations: Vec<String>,
245}
246
247#[derive(Deserialize)]
248struct WireEntityRef {
249 kind: EntityKind,
250 id: String,
251}
252
253fn agent_trace_subject(
254 evidence: &EvidenceBundle,
255) -> Result<agent_sdk_eval::EvaluationSubject, AgentError> {
256 let subject_ref = evidence
257 .outcome_ref
258 .clone()
259 .or_else(|| evidence.items.first().map(|item| item.evidence_ref.clone()))
260 .ok_or_else(|| AgentError::contract_violation("evaluation evidence bundle is empty"))?;
261 Ok(agent_sdk_eval::EvaluationSubject::primary(subject_ref))
262}
263
264fn ai_evaluator_prompt(
265 request: &EvaluationRequest,
266 evidence: &EvidenceBundle,
267) -> Result<String, AgentError> {
268 let payload = serde_json::json!({
269 "evaluation_id": request.evaluation_id.as_str(),
270 "scope": request.scope,
271 "expected_outcome": request.expected_outcome,
272 "comparison": request.comparison,
273 "deterministic_metric_deltas": request.metric_deltas,
274 "subjects": request.subjects,
275 "available_evidence": evidence.items,
276 "evidence_summary": evidence.redacted_summary,
277 });
278 let payload = serde_json::to_string(&payload).map_err(|error| {
279 AgentError::contract_violation(format!("evaluation prompt JSON encode failed: {error}"))
280 })?;
281 let prompt = format!(
282 concat!(
283 "Evaluate this recorded agent outcome using only the supplied redacted summaries and ids. ",
284 "Return compact JSON only with keys verdict, score, confidence, redacted_summary, support_refs, and limitations. ",
285 "Cite support_refs only from available_evidence. ",
286 "Use confidence=measured only when deterministic_metric_deltas and comparison evidence support it. ",
287 "Do not compute or return metric_deltas; deterministic_metric_deltas are the only metric authority. ",
288 "Do not include raw private content.\n",
289 "EVALUATION_PAYLOAD={payload}"
290 ),
291 payload = payload
292 );
293 Ok(truncate_chars(prompt, request.budget.max_prompt_chars))
294}
295
296fn report_from_provider_response(
297 request: &EvaluationRequest,
298 evidence: &EvidenceBundle,
299 output_text: &str,
300 usage: EvaluationUsage,
301) -> Result<EvaluationReport, AgentError> {
302 let mut limitations = Vec::new();
303 let parsed = serde_json::from_str::<AiEvaluatorReply>(output_text).map_err(|error| {
304 AgentError::contract_violation(format!("evaluation provider JSON parse failed: {error}"))
305 })?;
306 let (support_refs, parse_limitations) = raw_support_refs(parsed.support_refs);
307 limitations.extend(parse_limitations);
308 let support = evidence.validate_support_refs(support_refs, request.budget.max_support_refs);
309 limitations.extend(parsed.limitations);
310
311 let fallback_confidence = if support.accepted_refs.is_empty() {
312 EvaluationConfidence::Judged
313 } else {
314 EvaluationConfidence::Cited
315 };
316 let confidence = parsed.confidence.unwrap_or(fallback_confidence.clone());
317 let subject_ref = request
318 .subjects
319 .first()
320 .map(|subject| subject.subject_ref.clone())
321 .or_else(|| evidence.outcome_ref.clone())
322 .unwrap_or_else(|| EntityRef::run(RunId::new("run.evaluation.unknown")));
323 let mut judgment = EvaluatorJudgment::new(
324 subject_ref,
325 parsed
326 .verdict
327 .clone()
328 .unwrap_or(EvaluationVerdict::Inconclusive),
329 confidence.clone(),
330 parsed
331 .redacted_summary
332 .clone()
333 .unwrap_or_else(|| "AI evaluator returned a judgment".to_string()),
334 );
335 judgment.score = parsed.score.clone();
336 judgment.support_refs = support.accepted_refs.clone();
337 judgment.rejected_support_refs = support.rejected_refs.clone();
338
339 let mut report = EvaluationReport::new(
340 request.evaluation_id.clone(),
341 request.scope.clone(),
342 request.comparison.clone(),
343 parsed.verdict.unwrap_or(EvaluationVerdict::Inconclusive),
344 confidence,
345 parsed
346 .redacted_summary
347 .unwrap_or_else(|| "AI evaluator returned a report".to_string()),
348 )
349 .with_usage(usage)
350 .with_judgment(judgment);
351 report.score = parsed.score;
352 report.evidence_refs = support.accepted_refs;
353 report.metric_deltas = request.metric_deltas.clone();
354 report.limitations = limitations;
355
356 let claims_measured = report.confidence.is_measured()
357 || report
358 .judgments
359 .iter()
360 .any(|judgment| judgment.confidence.is_measured());
361 if report
362 .validate_confidence_contract_for_request(request)
363 .is_err()
364 && claims_measured
365 {
366 report.confidence = fallback_confidence.clone();
367 for judgment in &mut report.judgments {
368 if judgment.confidence.is_measured() {
369 judgment.confidence = fallback_confidence.clone();
370 }
371 }
372 report.limitations.push(
373 "evaluator requested measured confidence without sufficient comparison evidence; downgraded"
374 .to_string(),
375 );
376 }
377 report.validate_confidence_contract_for_request(request)?;
378 Ok(report)
379}
380
381fn raw_support_refs(raw_refs: Vec<WireEntityRef>) -> (Vec<EntityRef>, Vec<String>) {
382 let mut refs = Vec::new();
383 let mut limitations = Vec::new();
384 for raw_ref in raw_refs {
385 match EntityId::try_new(raw_ref.id) {
386 Ok(entity_id) => refs.push(EntityRef::new(raw_ref.kind, entity_id)),
387 Err(error) => {
388 limitations.push(format!("provider cited an invalid support ref: {error}"))
389 }
390 }
391 }
392 (refs, limitations)
393}
394
395fn truncate_chars(value: String, max_chars: usize) -> String {
396 if value.chars().count() <= max_chars {
397 return value;
398 }
399 value.chars().take(max_chars).collect()
400}