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 tools: Vec::new(),
222 };
223 let response = self.provider.complete(&provider_request)?;
224 let usage = EvaluationUsage {
225 provider_calls: 1,
226 provider_usage: Some(self.provider.extract_usage(&response)),
227 };
228 report_from_provider_response(request, evidence, &response.output_text, usage)
229 }
230}
231
232#[derive(Deserialize)]
233struct AiEvaluatorReply {
234 #[serde(default)]
235 verdict: Option<EvaluationVerdict>,
236 #[serde(default)]
237 score: Option<String>,
238 #[serde(default)]
239 confidence: Option<EvaluationConfidence>,
240 #[serde(default)]
241 redacted_summary: Option<String>,
242 #[serde(default)]
243 support_refs: Vec<WireEntityRef>,
244 #[serde(default)]
245 limitations: Vec<String>,
246}
247
248#[derive(Deserialize)]
249struct WireEntityRef {
250 kind: EntityKind,
251 id: String,
252}
253
254fn agent_trace_subject(
255 evidence: &EvidenceBundle,
256) -> Result<agent_sdk_eval::EvaluationSubject, AgentError> {
257 let subject_ref = evidence
258 .outcome_ref
259 .clone()
260 .or_else(|| evidence.items.first().map(|item| item.evidence_ref.clone()))
261 .ok_or_else(|| AgentError::contract_violation("evaluation evidence bundle is empty"))?;
262 Ok(agent_sdk_eval::EvaluationSubject::primary(subject_ref))
263}
264
265fn ai_evaluator_prompt(
266 request: &EvaluationRequest,
267 evidence: &EvidenceBundle,
268) -> Result<String, AgentError> {
269 let payload = serde_json::json!({
270 "evaluation_id": request.evaluation_id.as_str(),
271 "scope": request.scope,
272 "expected_outcome": request.expected_outcome,
273 "comparison": request.comparison,
274 "deterministic_metric_deltas": request.metric_deltas,
275 "subjects": request.subjects,
276 "available_evidence": evidence.items,
277 "evidence_summary": evidence.redacted_summary,
278 });
279 let payload = serde_json::to_string(&payload).map_err(|error| {
280 AgentError::contract_violation(format!("evaluation prompt JSON encode failed: {error}"))
281 })?;
282 let prompt = format!(
283 concat!(
284 "Evaluate this recorded agent outcome using only the supplied redacted summaries and ids. ",
285 "Return compact JSON only with keys verdict, score, confidence, redacted_summary, support_refs, and limitations. ",
286 "Cite support_refs only from available_evidence. ",
287 "Use confidence=measured only when deterministic_metric_deltas and comparison evidence support it. ",
288 "Do not compute or return metric_deltas; deterministic_metric_deltas are the only metric authority. ",
289 "Do not include raw private content.\n",
290 "EVALUATION_PAYLOAD={payload}"
291 ),
292 payload = payload
293 );
294 Ok(truncate_chars(prompt, request.budget.max_prompt_chars))
295}
296
297fn report_from_provider_response(
298 request: &EvaluationRequest,
299 evidence: &EvidenceBundle,
300 output_text: &str,
301 usage: EvaluationUsage,
302) -> Result<EvaluationReport, AgentError> {
303 let mut limitations = Vec::new();
304 let parsed = serde_json::from_str::<AiEvaluatorReply>(output_text).map_err(|error| {
305 AgentError::contract_violation(format!("evaluation provider JSON parse failed: {error}"))
306 })?;
307 let (support_refs, parse_limitations) = raw_support_refs(parsed.support_refs);
308 limitations.extend(parse_limitations);
309 let support = evidence.validate_support_refs(support_refs, request.budget.max_support_refs);
310 limitations.extend(parsed.limitations);
311
312 let fallback_confidence = if support.accepted_refs.is_empty() {
313 EvaluationConfidence::Judged
314 } else {
315 EvaluationConfidence::Cited
316 };
317 let confidence = parsed.confidence.unwrap_or(fallback_confidence.clone());
318 let subject_ref = request
319 .subjects
320 .first()
321 .map(|subject| subject.subject_ref.clone())
322 .or_else(|| evidence.outcome_ref.clone())
323 .unwrap_or_else(|| EntityRef::run(RunId::new("run.evaluation.unknown")));
324 let mut judgment = EvaluatorJudgment::new(
325 subject_ref,
326 parsed
327 .verdict
328 .clone()
329 .unwrap_or(EvaluationVerdict::Inconclusive),
330 confidence.clone(),
331 parsed
332 .redacted_summary
333 .clone()
334 .unwrap_or_else(|| "AI evaluator returned a judgment".to_string()),
335 );
336 judgment.score = parsed.score.clone();
337 judgment.support_refs = support.accepted_refs.clone();
338 judgment.rejected_support_refs = support.rejected_refs.clone();
339
340 let mut report = EvaluationReport::new(
341 request.evaluation_id.clone(),
342 request.scope.clone(),
343 request.comparison.clone(),
344 parsed.verdict.unwrap_or(EvaluationVerdict::Inconclusive),
345 confidence,
346 parsed
347 .redacted_summary
348 .unwrap_or_else(|| "AI evaluator returned a report".to_string()),
349 )
350 .with_usage(usage)
351 .with_judgment(judgment);
352 report.score = parsed.score;
353 report.evidence_refs = support.accepted_refs;
354 report.metric_deltas = request.metric_deltas.clone();
355 report.limitations = limitations;
356
357 let claims_measured = report.confidence.is_measured()
358 || report
359 .judgments
360 .iter()
361 .any(|judgment| judgment.confidence.is_measured());
362 if report
363 .validate_confidence_contract_for_request(request)
364 .is_err()
365 && claims_measured
366 {
367 report.confidence = fallback_confidence.clone();
368 for judgment in &mut report.judgments {
369 if judgment.confidence.is_measured() {
370 judgment.confidence = fallback_confidence.clone();
371 }
372 }
373 report.limitations.push(
374 "evaluator requested measured confidence without sufficient comparison evidence; downgraded"
375 .to_string(),
376 );
377 }
378 report.validate_confidence_contract_for_request(request)?;
379 Ok(report)
380}
381
382fn raw_support_refs(raw_refs: Vec<WireEntityRef>) -> (Vec<EntityRef>, Vec<String>) {
383 let mut refs = Vec::new();
384 let mut limitations = Vec::new();
385 for raw_ref in raw_refs {
386 match EntityId::try_new(raw_ref.id) {
387 Ok(entity_id) => refs.push(EntityRef::new(raw_ref.kind, entity_id)),
388 Err(error) => {
389 limitations.push(format!("provider cited an invalid support ref: {error}"))
390 }
391 }
392 }
393 (refs, limitations)
394}
395
396fn truncate_chars(value: String, max_chars: usize) -> String {
397 if value.chars().count() <= max_chars {
398 return value;
399 }
400 value.chars().take(max_chars).collect()
401}