Skip to main content

agent_infra_cli/
evaluate_cli.rs

1//! Evaluate subcommands.
2//!
3//! Kept apart from the top-level command table because the evaluation
4//! workflow has its own vocabulary: bundles, gates, reviews and metrics.
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use agent_infra_sdk::{
10    EvaluateClient, InfraClient, WaitOptions,
11    evaluate_contract::{
12        ClaimReviewRequest, CompareReportsRequest, CompleteReviewRequest, CreateEvalRunRequest,
13        GatePolicy, GetOperationRequest, GetReportRequest, ListCaseRunsRequest,
14        ListMetricCandidatesRequest, MetricHealthRequest, RegisterEnvironmentProfileRequest,
15        RegisterEvalCaseRequest, RegisterEvaluatorVersionRequest, RegisterSuiteRequest,
16        ReportComparison, SampleReviewsRequest,
17    },
18};
19use anyhow::{Context, Result, bail};
20use clap::Subcommand;
21
22use crate::{CommandOutput, JsonInput, display_source, evaluate_bundle, read_input};
23
24#[derive(Debug, Subcommand)]
25pub enum EvaluateCommand {
26    RegisterCase(JsonInput),
27    RegisterEnvironment(JsonInput),
28    RegisterEvaluator(JsonInput),
29    RegisterSuite(JsonInput),
30    /// Register a bundle whose versions are derived from its content.
31    ///
32    /// Re-pushing an unchanged bundle is a no-op, so nothing has to be
33    /// version-bumped by hand and metric history stays continuous.
34    Push {
35        #[command(flatten)]
36        input: JsonInput,
37        /// Print the resolved versions without contacting the Gateway.
38        #[arg(long)]
39        dry_run: bool,
40    },
41    /// Create a durable task that a matching Runtime starts through POST /evaluate.
42    #[command(alias = "run")]
43    TaskCreate(JsonInput),
44    /// Create a task, wait for it to finish, and print its report.
45    RunWait {
46        #[command(flatten)]
47        input: JsonInput,
48        #[arg(long, default_value_t = 1800, value_parser = clap::value_parser!(u64).range(1..=86_400))]
49        wait_timeout: u64,
50    },
51    Operation {
52        operation_id: String,
53    },
54    Cases(JsonInput),
55    Report {
56        report_id: String,
57    },
58    /// Compare two reports and exit non-zero when the gate blocks.
59    Diff {
60        #[arg(long)]
61        report_id: String,
62        #[arg(long)]
63        baseline_report_id: String,
64        /// GatePolicy JSON document. Defaults to the built-in policy.
65        #[arg(long)]
66        policy: Option<PathBuf>,
67        /// Report the gate decision without failing the command.
68        #[arg(long)]
69        no_fail: bool,
70    },
71    /// Error-analysis review queue.
72    Review {
73        #[command(subcommand)]
74        command: EvaluateReviewCommand,
75    },
76    /// Metric health and candidate metrics.
77    Metrics {
78        #[command(subcommand)]
79        command: EvaluateMetricsCommand,
80    },
81}
82
83#[derive(Debug, Subcommand)]
84pub enum EvaluateReviewCommand {
85    /// Draw a stratified review batch, passing cases included.
86    Sample(JsonInput),
87    /// Claim the next queued review, if any.
88    Claim {
89        #[arg(long, default_value_t = 300_000, value_parser = clap::value_parser!(i64).range(10_000..=3_600_000))]
90        lease_ms: i64,
91    },
92    /// Record a reviewer's note, labels and verdict.
93    Complete(JsonInput),
94}
95
96#[derive(Debug, Subcommand)]
97pub enum EvaluateMetricsCommand {
98    /// Per-metric statistics and retirement recommendations.
99    Health {
100        #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..=1_000))]
101        window_runs: u32,
102        #[arg(long)]
103        evaluator_id: Option<String>,
104    },
105    /// Labelled failure modes ranked by how well they predict the verdict.
106    Candidates {
107        #[arg(long)]
108        run_id: Option<String>,
109        #[arg(long, default_value_t = 3)]
110        minimum_occurrences: u64,
111    },
112}
113
114pub(crate) async fn execute_evaluate(
115    client: &InfraClient,
116    command: EvaluateCommand,
117) -> Result<CommandOutput> {
118    let evaluate = client.evaluate();
119    match command {
120        EvaluateCommand::RegisterCase(input) => {
121            let request: RegisterEvalCaseRequest = input.read()?;
122            CommandOutput::json(evaluate.register_eval_case(&request).await?)
123        }
124        EvaluateCommand::RegisterEnvironment(input) => {
125            let request: RegisterEnvironmentProfileRequest = input.read()?;
126            CommandOutput::json(evaluate.register_environment_profile(&request).await?)
127        }
128        EvaluateCommand::RegisterEvaluator(input) => {
129            let request: RegisterEvaluatorVersionRequest = input.read()?;
130            CommandOutput::json(evaluate.register_evaluator_version(&request).await?)
131        }
132        EvaluateCommand::RegisterSuite(input) => {
133            let request: RegisterSuiteRequest = input.read()?;
134            CommandOutput::json(evaluate.register_suite(&request).await?)
135        }
136        EvaluateCommand::Push { input, dry_run } => {
137            let bundle: evaluate_bundle::EvalBundle = input.read()?;
138            let resolved = evaluate_bundle::resolve(&bundle)?;
139            let summary = serde_json::json!({
140                "suiteId": resolved.suite.suite_id,
141                "suiteVersion": resolved.suite.version,
142                "manifestDigest": resolved.suite.manifest_digest,
143                "environmentProfiles": resolved
144                    .environment_profiles
145                    .iter()
146                    .map(|profile| serde_json::json!({
147                        "profileId": profile.profile_id,
148                        "version": profile.version,
149                    }))
150                    .collect::<Vec<_>>(),
151                "evaluators": resolved
152                    .evaluators
153                    .iter()
154                    .map(|evaluator| serde_json::json!({
155                        "evaluatorId": evaluator.evaluator_id,
156                        "version": evaluator.version,
157                        "class": evaluator.metric.class,
158                    }))
159                    .collect::<Vec<_>>(),
160                "cases": resolved
161                    .cases
162                    .iter()
163                    .map(|case| serde_json::json!({
164                        "caseId": case.case_id,
165                        "version": case.version,
166                    }))
167                    .collect::<Vec<_>>(),
168                "applied": !dry_run,
169            });
170            if dry_run {
171                return CommandOutput::json(summary);
172            }
173            // Dependencies first: a case registration is rejected unless its
174            // environment and evaluators resolve, and a suite registration is
175            // rejected unless every case version resolves.
176            for profile in &resolved.environment_profiles {
177                evaluate.register_environment_profile(profile).await?;
178            }
179            for evaluator in &resolved.evaluators {
180                evaluate.register_evaluator_version(evaluator).await?;
181            }
182            for case in &resolved.cases {
183                evaluate.register_eval_case(case).await?;
184            }
185            evaluate.register_suite(&resolved.suite).await?;
186            CommandOutput::json(summary)
187        }
188        EvaluateCommand::TaskCreate(input) => {
189            let request: CreateEvalRunRequest = input.read()?;
190            let handle = evaluate.create_task(&request).await?;
191            let operation = handle.refresh().await?;
192            CommandOutput::json(serde_json::json!({
193                "evaluateTaskId": operation.id,
194                "operation": operation,
195            }))
196        }
197        EvaluateCommand::RunWait {
198            input,
199            wait_timeout,
200        } => {
201            let request: CreateEvalRunRequest = input.read()?;
202            let report = evaluate
203                .run_and_wait(
204                    &request,
205                    WaitOptions::deadline(Duration::from_secs(wait_timeout)),
206                )
207                .await?;
208            CommandOutput::json(report)
209        }
210        EvaluateCommand::Operation { operation_id } => {
211            let request = GetOperationRequest { operation_id };
212            let handle = evaluate.operation(request.operation_id).await?;
213            CommandOutput::json(handle.refresh().await?)
214        }
215        EvaluateCommand::Cases(input) => {
216            let request: ListCaseRunsRequest = input.read()?;
217            CommandOutput::json(evaluate.list_case_runs(&request).await?)
218        }
219        EvaluateCommand::Report { report_id } => {
220            let request = GetReportRequest { report_id };
221            CommandOutput::json(evaluate.report(&request.report_id).await?)
222        }
223        EvaluateCommand::Diff {
224            report_id,
225            baseline_report_id,
226            policy,
227            no_fail,
228        } => {
229            let policy = policy
230                .map(|path| -> Result<GatePolicy> {
231                    let bytes = read_input(&path)?;
232                    serde_json::from_slice(&bytes)
233                        .with_context(|| format!("failed to parse {}", display_source(&path)))
234                })
235                .transpose()?;
236            let comparison = evaluate
237                .compare_reports(&CompareReportsRequest {
238                    report_id,
239                    baseline_report_id,
240                    minimum_pass_rate: 0.0,
241                    maximum_infra_failure_rate: 1.0,
242                    significance_level: 0.05,
243                    minimum_effect: 0.0,
244                    policy,
245                })
246                .await?;
247            if !comparison.gate_passed && !no_fail {
248                // A blocking metric must fail the command so CI stops without
249                // the caller having to parse the JSON.
250                bail!("{}", describe_gate_failure(&comparison));
251            }
252            CommandOutput::json(comparison)
253        }
254        EvaluateCommand::Review { command } => execute_evaluate_review(evaluate, command).await,
255        EvaluateCommand::Metrics { command } => execute_evaluate_metrics(evaluate, command).await,
256    }
257}
258
259fn describe_gate_failure(comparison: &ReportComparison) -> String {
260    let blocking = comparison
261        .gate
262        .blocking_metrics
263        .iter()
264        .map(|violation| {
265            format!(
266                "{} [{}] {}",
267                violation.evaluator_id,
268                violation.class.as_str(),
269                violation.detail
270            )
271        })
272        .collect::<Vec<_>>()
273        .join("; ");
274    if blocking.is_empty() {
275        "evaluation gate failed".to_string()
276    } else {
277        format!("evaluation gate blocked by {blocking}")
278    }
279}
280
281async fn execute_evaluate_review(
282    evaluate: &EvaluateClient,
283    command: EvaluateReviewCommand,
284) -> Result<CommandOutput> {
285    match command {
286        EvaluateReviewCommand::Sample(input) => {
287            let request: SampleReviewsRequest = input.read()?;
288            CommandOutput::json(evaluate.sample_reviews(&request).await?)
289        }
290        EvaluateReviewCommand::Claim { lease_ms } => {
291            match evaluate
292                .claim_review(&ClaimReviewRequest { lease_ms })
293                .await?
294            {
295                Some(review) => CommandOutput::json(review),
296                None => CommandOutput::json(serde_json::json!({ "queued": false })),
297            }
298        }
299        EvaluateReviewCommand::Complete(input) => {
300            let request: CompleteReviewRequest = input.read()?;
301            CommandOutput::json(evaluate.complete_review(&request).await?)
302        }
303    }
304}
305
306async fn execute_evaluate_metrics(
307    evaluate: &EvaluateClient,
308    command: EvaluateMetricsCommand,
309) -> Result<CommandOutput> {
310    match command {
311        EvaluateMetricsCommand::Health {
312            window_runs,
313            evaluator_id,
314        } => CommandOutput::json(
315            evaluate
316                .metric_health(&MetricHealthRequest {
317                    window_runs,
318                    evaluator_id,
319                })
320                .await?,
321        ),
322        EvaluateMetricsCommand::Candidates {
323            run_id,
324            minimum_occurrences,
325        } => CommandOutput::json(
326            evaluate
327                .metric_candidates(&ListMetricCandidatesRequest {
328                    run_id,
329                    minimum_occurrences,
330                })
331                .await?,
332        ),
333    }
334}