Skip to main content

agent_sdk_toolkit/
evaluation.rs

1//! Toolkit helpers for post-hoc agent-run evaluation.
2
3use 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/// Builder for evaluating an agent trace with an arbitrary evaluator.
20#[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    /// Builds an evaluation from a turn trace.
30    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    /// Builds an evaluation from a run trace.
51    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    /// Builds an evaluation from a session timeline.
72    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    /// Builds a deterministic comparison between two session timelines.
96    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    /// Returns this evaluation with its comparison design replaced.
142    pub fn compare(mut self, comparison: ComparisonDesign) -> Self {
143        self.request.comparison = comparison;
144        self
145    }
146
147    /// Returns this evaluation with its evaluator budget replaced.
148    pub fn with_budget(mut self, budget: EvaluationBudget) -> Self {
149        self.request.budget = budget;
150        self
151    }
152
153    /// Evaluates this trace using the supplied evaluator.
154    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    /// Returns the request this builder will evaluate.
161    pub fn request(&self) -> &EvaluationRequest {
162        &self.request
163    }
164
165    /// Returns the evidence bundle this builder will pass to the evaluator.
166    pub fn evidence(&self) -> &EvidenceBundle {
167        &self.evidence
168    }
169
170    /// Returns deterministic metrics for the observed trace.
171    pub fn metrics(&self) -> &TraceMetrics {
172        &self.metrics
173    }
174
175    /// Returns deterministic comparison metrics when this evaluation compares traces.
176    pub fn metrics_comparison(&self) -> Option<&TraceMetricsComparison> {
177        self.metrics_comparison.as_ref()
178    }
179}
180
181/// Provider-backed evaluator that asks for cited evidence refs and validates
182/// them against the supplied bundle.
183pub struct AiTraceEvaluator {
184    provider: Arc<dyn ProviderAdapter>,
185}
186
187impl AiTraceEvaluator {
188    /// Creates an AI evaluator from a provider adapter.
189    pub fn new(provider: Arc<dyn ProviderAdapter>) -> Self {
190        Self { provider }
191    }
192
193    /// Creates an AI evaluator from a concrete provider adapter.
194    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}