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            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}