Skip to main content

agent_infra_cli/
eval_cli.rs

1//! `infra eval` — the Evaluate domain application.
2//!
3//! Evaluate now ships from its own repository; these commands speak its
4//! snapshot-centric contract. The older `infra evaluate` commands remain for
5//! the deployment that still runs the previous service.
6
7use std::time::Duration;
8
9use agent_infra_sdk::{
10    InfraClient,
11    eval_contract::{
12        BindingTarget, BoundSnapshot, Capability, CompareReportsRequest, CreateRunRequest,
13        EvaluationSubject, ExportBundleRequest, GatePolicy, JudgeAllowlistEntry,
14        ListAdminBindingsRequest, ListAdminRunsRequest, ListAdminSnapshotsRequest,
15        ListBindingsRequest, ListCaseRunsRequest, ListSnapshotsRequest, OwnerKind,
16        PutBindingRequest, RegisterSnapshotRequest, SnapshotBundle, SnapshotRef, SnapshotSelection,
17    },
18};
19use anyhow::{Context, Result, bail};
20use clap::{Args, Subcommand};
21
22use crate::{CommandOutput, JsonInput};
23
24/// Polling stays deliberately modest so waiting in CI does not hammer the API.
25const POLL_INTERVAL: Duration = Duration::from_secs(3);
26
27#[derive(Debug, Subcommand)]
28pub enum EvalCommand {
29    /// List evaluation snapshots visible to this tenant.
30    Snapshots(SnapshotFilter),
31    /// Show one snapshot, e.g. `builtin/tool-use@2026-08-20.1`.
32    Snapshot { reference: String },
33    /// Validate a snapshot file's JSON schema locally, without contacting the Gateway.
34    Validate(JsonInput),
35    /// Register a tenant snapshot from a file.
36    Publish(JsonInput),
37    /// Bind snapshots to an agent or runtime.
38    Bind(BindArgs),
39    /// List bindings.
40    Bindings(PageArgs),
41    /// Remove a binding.
42    Unbind(TargetArgs),
43    /// Create an evaluation run.
44    Run(RunArgs),
45    /// Read an evaluate task.
46    Operation { evaluate_task_id: String },
47    /// List a run's case runs.
48    Cases {
49        #[arg(long)]
50        run: String,
51        #[command(flatten)]
52        page: PageArgs,
53    },
54    /// Read a sealed report.
55    Report {
56        #[arg(long)]
57        run: String,
58        #[arg(long)]
59        revision: Option<u32>,
60    },
61    /// Compare a run against a baseline; exits non-zero when the gate blocks.
62    Gate(GateArgs),
63    /// Cancel a run.
64    Cancel {
65        #[arg(long)]
66        run: String,
67    },
68    /// Platform catalogue operations (requires an admin credential).
69    Admin {
70        #[command(subcommand)]
71        command: EvalAdminCommand,
72    },
73}
74
75#[derive(Debug, Args)]
76pub struct SnapshotFilter {
77    #[arg(long, value_parser = parse_owner)]
78    pub owner: Option<OwnerKind>,
79    #[arg(long, value_parser = parse_capability)]
80    pub capability: Option<Capability>,
81    #[arg(long)]
82    pub limit: Option<usize>,
83    #[arg(long)]
84    pub cursor: Option<String>,
85}
86
87#[derive(Debug, Args)]
88pub struct PageArgs {
89    #[arg(long)]
90    pub cursor: Option<String>,
91    #[arg(long)]
92    pub limit: Option<usize>,
93}
94
95#[derive(Debug, Args)]
96pub struct TargetArgs {
97    /// Agent under evaluation.
98    #[arg(long, group = "target")]
99    pub agent: Option<String>,
100    /// Runtime under evaluation.
101    #[arg(long, group = "target")]
102    pub runtime: Option<String>,
103}
104
105impl TargetArgs {
106    fn resolve(&self) -> Result<BindingTarget> {
107        match (&self.agent, &self.runtime) {
108            (Some(agent), None) => Ok(BindingTarget::agent(agent)),
109            (None, Some(runtime)) => Ok(BindingTarget::runtime(runtime)),
110            _ => bail!("exactly one of --agent or --runtime is required"),
111        }
112    }
113}
114
115#[derive(Debug, Args)]
116pub struct BindArgs {
117    #[command(flatten)]
118    pub target: TargetArgs,
119    /// Snapshot that must pass, e.g. `builtin/tool-use@2026-08-20.1`.
120    #[arg(long = "snapshot", required = true)]
121    pub snapshots: Vec<String>,
122    /// Snapshot that is observed but never gates.
123    #[arg(long = "optional")]
124    pub optional: Vec<String>,
125}
126
127#[derive(Debug, Args)]
128pub struct RunArgs {
129    #[command(flatten)]
130    pub target: TargetArgs,
131    /// Run the target's binding instead of naming snapshots.
132    #[arg(long)]
133    pub use_binding: bool,
134    #[arg(long = "snapshot")]
135    pub snapshots: Vec<String>,
136    /// Runtime that will execute the cases; required for out-of-sandbox runs.
137    #[arg(long)]
138    pub subject_runtime: Option<String>,
139    /// Agent the runtime is expected to be bound to.
140    #[arg(long)]
141    pub subject_agent: Option<String>,
142    #[arg(long, default_value = "")]
143    pub subject_version: String,
144    #[arg(long, default_value_t = 1)]
145    pub samples: u32,
146    #[arg(long)]
147    pub idempotency_key: Option<String>,
148    /// Poll until the run reaches a terminal state.
149    #[arg(long)]
150    pub wait: bool,
151    #[arg(long, default_value_t = 1800)]
152    pub wait_timeout: u64,
153}
154
155#[derive(Debug, Args)]
156pub struct GateArgs {
157    #[arg(long)]
158    pub run: String,
159    #[arg(long)]
160    pub baseline: String,
161    #[arg(long)]
162    pub revision: Option<u32>,
163    #[arg(long)]
164    pub baseline_revision: Option<u32>,
165    #[arg(long, default_value_t = 0.0)]
166    pub minimum_pass_rate: f64,
167    #[arg(long, default_value_t = 1.0)]
168    pub maximum_infra_failure_rate: f64,
169}
170
171#[derive(Debug, Subcommand)]
172pub enum EvalAdminCommand {
173    Overview,
174    Snapshots {
175        #[arg(long)]
176        lifecycle: Option<String>,
177        #[arg(long)]
178        tenant: Option<String>,
179        #[command(flatten)]
180        page: PageArgs,
181    },
182    Runs {
183        #[arg(long)]
184        tenant: Option<String>,
185        #[command(flatten)]
186        page: PageArgs,
187    },
188    Bindings {
189        #[arg(long)]
190        tenant: Option<String>,
191        #[command(flatten)]
192        page: PageArgs,
193    },
194    /// Register a platform snapshot; it starts as a draft.
195    Register(JsonInput),
196    Publish {
197        reference: String,
198    },
199    Deprecate {
200        reference: String,
201        #[arg(long)]
202        replacement: Option<String>,
203    },
204    /// Export published platform snapshots as a portable bundle.
205    Export,
206    /// Import a bundle produced by `export`.
207    Import(JsonInput),
208    /// Allow a judge model to back gating evaluators.
209    ApproveJudge {
210        model_ref: String,
211        #[arg(long)]
212        labeled_samples: u64,
213        #[arg(long)]
214        human_agreement: f64,
215        #[arg(long, default_value = "standard")]
216        cost_tier: String,
217        #[arg(long, default_value = "")]
218        notes: String,
219    },
220    /// List judge models and their gate approval state.
221    Judges,
222}
223
224pub async fn execute_eval(client: &InfraClient, command: EvalCommand) -> Result<CommandOutput> {
225    let eval = client.eval();
226    match command {
227        EvalCommand::Snapshots(filter) => CommandOutput::json(
228            eval.snapshots(&ListSnapshotsRequest {
229                owner: filter.owner,
230                capability: filter.capability,
231                snapshot_id: None,
232                cursor: filter.cursor,
233                limit: filter.limit,
234            })
235            .await?,
236        ),
237        EvalCommand::Snapshot { reference } => {
238            CommandOutput::json(eval.snapshot(&parse_ref(&reference)?).await?)
239        }
240        EvalCommand::Validate(input) => {
241            // Semantic checks that need includes, the judge allowlist or
242            // project quota remain authoritative on the server.
243            let request: RegisterSnapshotRequest = input.read()?;
244            CommandOutput::json(serde_json::json!({
245                "snapshotId": request.snapshot_id,
246                "version": request.version,
247                "cases": request.cases.len(),
248                "includes": request.includes.len(),
249                "schemaValid": true,
250                "serverValidationRequired": true,
251            }))
252        }
253        EvalCommand::Publish(input) => {
254            let request: RegisterSnapshotRequest = input.read()?;
255            CommandOutput::json(eval.register_snapshot(&request).await?)
256        }
257        EvalCommand::Bind(args) => {
258            let mut snapshots = Vec::new();
259            for reference in &args.snapshots {
260                snapshots.push(BoundSnapshot {
261                    reference: parse_ref(reference)?,
262                    required: true,
263                });
264            }
265            for reference in &args.optional {
266                snapshots.push(BoundSnapshot {
267                    reference: parse_ref(reference)?,
268                    required: false,
269                });
270            }
271            let target = args.target.resolve()?;
272            // A binding write is create-or-update; read the current version so
273            // an edit cannot silently clobber a concurrent one.
274            let if_version = eval.binding(&target).await?.map(|binding| binding.version);
275            CommandOutput::json(
276                eval.put_binding(&PutBindingRequest {
277                    target,
278                    snapshots,
279                    gate_policy: None,
280                    triggers: Vec::new(),
281                    if_version,
282                })
283                .await?,
284            )
285        }
286        EvalCommand::Bindings(page) => CommandOutput::json(
287            eval.bindings(&ListBindingsRequest {
288                cursor: page.cursor,
289                limit: page.limit,
290                ..ListBindingsRequest::default()
291            })
292            .await?,
293        ),
294        EvalCommand::Unbind(target) => {
295            CommandOutput::json(eval.delete_binding(&target.resolve()?).await?)
296        }
297        EvalCommand::Run(args) => run(client, args).await,
298        EvalCommand::Operation { evaluate_task_id } => {
299            CommandOutput::json(eval.operation(&evaluate_task_id).await?)
300        }
301        EvalCommand::Cases { run, page } => CommandOutput::json(
302            eval.case_runs(&ListCaseRunsRequest {
303                run_id: run,
304                cursor: page.cursor,
305                limit: page.limit,
306                ..ListCaseRunsRequest::default()
307            })
308            .await?,
309        ),
310        EvalCommand::Report { run, revision } => {
311            CommandOutput::json(eval.report(&run, revision).await?)
312        }
313        EvalCommand::Gate(args) => gate(client, args).await,
314        EvalCommand::Cancel { run } => CommandOutput::json(eval.cancel_run(&run).await?),
315        EvalCommand::Admin { command } => admin(client, command).await,
316    }
317}
318
319async fn run(client: &InfraClient, args: RunArgs) -> Result<CommandOutput> {
320    let eval = client.eval();
321    let target = args.target.resolve()?;
322
323    validate_run_selection(args.use_binding, &args.snapshots)?;
324
325    let mut snapshots = Vec::new();
326    for reference in &args.snapshots {
327        snapshots.push(SnapshotSelection {
328            reference: parse_ref(reference)?,
329            case_ids: Vec::new(),
330        });
331    }
332
333    let subject = resolve_subject(
334        &target,
335        args.subject_agent.as_deref(),
336        args.subject_runtime.as_deref(),
337    )?;
338
339    let request = CreateRunRequest {
340        target,
341        use_binding: args.use_binding,
342        snapshots,
343        subject,
344        subject_version: args.subject_version,
345        execution_override: None,
346        baseline_report_id: None,
347        seed: 0,
348        concurrency: 8,
349        samples: args.samples,
350        experiment_matrix: Vec::new(),
351        idempotency_key: args.idempotency_key.unwrap_or_default(),
352    };
353
354    let operation = eval.create_run(&request).await?;
355    if !args.wait {
356        return CommandOutput::json(operation);
357    }
358
359    let deadline = std::time::Instant::now() + Duration::from_secs(args.wait_timeout);
360    let mut latest = operation;
361    while !latest.state.is_terminal() {
362        if std::time::Instant::now() >= deadline {
363            bail!(
364                "run {} did not finish within {}s ({} of {} cases done)",
365                latest.run_id,
366                args.wait_timeout,
367                latest.completed_cases,
368                latest.total_cases
369            );
370        }
371        tokio::time::sleep(POLL_INTERVAL).await;
372        latest = eval.operation(&latest.id).await?;
373    }
374
375    if latest.report_id.is_some() {
376        CommandOutput::json(eval.report(&latest.run_id, None).await?)
377    } else {
378        CommandOutput::json(latest)
379    }
380}
381
382fn validate_run_selection(use_binding: bool, snapshots: &[String]) -> Result<()> {
383    match (use_binding, snapshots.is_empty()) {
384        (true, false) => bail!("--use-binding cannot be combined with --snapshot"),
385        (false, true) => bail!("one or more --snapshot values or --use-binding is required"),
386        _ => Ok(()),
387    }
388}
389
390fn resolve_subject(
391    target: &BindingTarget,
392    subject_agent: Option<&str>,
393    subject_runtime: Option<&str>,
394) -> Result<Option<EvaluationSubject>> {
395    match (subject_agent, subject_runtime) {
396        (Some(agent_id), Some(runtime_id)) => Ok(Some(EvaluationSubject {
397            agent_id: agent_id.to_string(),
398            runtime_id: runtime_id.to_string(),
399        })),
400        (None, Some(runtime_id))
401            if target.kind == agent_infra_sdk::eval_contract::TargetKind::Agent =>
402        {
403            Ok(Some(EvaluationSubject {
404                agent_id: target.id.clone(),
405                runtime_id: runtime_id.to_string(),
406            }))
407        }
408        (None, None) => Ok(None),
409        _ => bail!(
410            "--subject-agent and --subject-runtime must be provided together unless the target is an agent"
411        ),
412    }
413}
414
415async fn gate(client: &InfraClient, args: GateArgs) -> Result<CommandOutput> {
416    let decision = client
417        .eval()
418        .compare_reports(&CompareReportsRequest {
419            run_id: args.run,
420            revision: args.revision,
421            baseline_run_id: args.baseline,
422            baseline_revision: args.baseline_revision,
423            policy: GatePolicy {
424                minimum_pass_rate: args.minimum_pass_rate,
425                maximum_infra_failure_rate: args.maximum_infra_failure_rate,
426                ..GatePolicy::default()
427            },
428        })
429        .await?;
430
431    if !decision.allowed {
432        let reasons = decision
433            .violations
434            .iter()
435            .map(|violation| format!("{}: {}", violation.evaluator_id, violation.reason))
436            .collect::<Vec<_>>()
437            .join("; ");
438        bail!("evaluation gate blocked the release: {reasons}");
439    }
440    CommandOutput::json(decision)
441}
442
443async fn admin(client: &InfraClient, command: EvalAdminCommand) -> Result<CommandOutput> {
444    let eval = client.eval();
445    match command {
446        EvalAdminCommand::Overview => CommandOutput::json(eval.admin_overview().await?),
447        EvalAdminCommand::Snapshots {
448            lifecycle,
449            tenant,
450            page,
451        } => {
452            let lifecycle = lifecycle
453                .map(|value| {
454                    serde_json::from_value(serde_json::Value::String(value.clone()))
455                        .with_context(|| format!("unknown lifecycle `{value}`"))
456                })
457                .transpose()?;
458            CommandOutput::json(
459                eval.admin_snapshots(&ListAdminSnapshotsRequest {
460                    lifecycle,
461                    tenant_id: tenant,
462                    cursor: page.cursor,
463                    limit: page.limit,
464                    ..ListAdminSnapshotsRequest::default()
465                })
466                .await?,
467            )
468        }
469        EvalAdminCommand::Runs { tenant, page } => CommandOutput::json(
470            eval.admin_runs(&ListAdminRunsRequest {
471                tenant_id: tenant,
472                cursor: page.cursor,
473                limit: page.limit,
474                ..ListAdminRunsRequest::default()
475            })
476            .await?,
477        ),
478        EvalAdminCommand::Bindings { tenant, page } => CommandOutput::json(
479            eval.admin_bindings(&ListAdminBindingsRequest {
480                tenant_id: tenant,
481                cursor: page.cursor,
482                limit: page.limit,
483                ..ListAdminBindingsRequest::default()
484            })
485            .await?,
486        ),
487        EvalAdminCommand::Register(input) => {
488            let request: RegisterSnapshotRequest = input.read()?;
489            CommandOutput::json(eval.admin_register_snapshot(&request).await?)
490        }
491        EvalAdminCommand::Publish { reference } => {
492            CommandOutput::json(eval.admin_publish(&parse_ref(&reference)?).await?)
493        }
494        EvalAdminCommand::Deprecate {
495            reference,
496            replacement,
497        } => {
498            let replacement = replacement.as_deref().map(parse_ref).transpose()?;
499            CommandOutput::json(
500                eval.admin_deprecate(&parse_ref(&reference)?, replacement)
501                    .await?,
502            )
503        }
504        EvalAdminCommand::Export => {
505            CommandOutput::json(eval.admin_export(&ExportBundleRequest::default()).await?)
506        }
507        EvalAdminCommand::Import(input) => {
508            let bundle: SnapshotBundle = input.read()?;
509            CommandOutput::json(eval.admin_import(&bundle).await?)
510        }
511        EvalAdminCommand::ApproveJudge {
512            model_ref,
513            labeled_samples,
514            human_agreement,
515            cost_tier,
516            notes,
517        } => CommandOutput::json(
518            eval.admin_approve_judge(&JudgeAllowlistEntry {
519                model_ref,
520                gate_approved: true,
521                labeled_samples,
522                human_agreement,
523                cost_tier,
524                notes,
525                updated_at_ms: now_ms(),
526            })
527            .await?,
528        ),
529        EvalAdminCommand::Judges => CommandOutput::json(eval.admin_judge_allowlist().await?),
530    }
531}
532
533fn parse_ref(value: &str) -> Result<SnapshotRef> {
534    value
535        .parse()
536        .with_context(|| format!("invalid snapshot reference `{value}`"))
537}
538
539fn parse_owner(value: &str) -> Result<OwnerKind, String> {
540    match value {
541        "builtin" => Ok(OwnerKind::Builtin),
542        "tenant" => Ok(OwnerKind::Tenant),
543        other => Err(format!(
544            "unknown owner `{other}`; expected builtin or tenant"
545        )),
546    }
547}
548
549fn parse_capability(value: &str) -> Result<Capability, String> {
550    serde_json::from_value(serde_json::Value::String(value.to_string())).map_err(|_| {
551        let known = Capability::ALL
552            .iter()
553            .map(|capability| capability.as_str())
554            .collect::<Vec<_>>()
555            .join(", ");
556        format!("unknown capability `{value}`; expected one of {known}")
557    })
558}
559
560fn now_ms() -> i64 {
561    std::time::SystemTime::now()
562        .duration_since(std::time::UNIX_EPOCH)
563        .map(|elapsed| elapsed.as_millis() as i64)
564        .unwrap_or_default()
565}
566
567#[cfg(test)]
568mod tests {
569    use super::{
570        EvalAdminCommand, EvalCommand, TargetArgs, parse_capability, parse_ref, resolve_subject,
571        validate_run_selection,
572    };
573    use crate::{Cli, Command};
574    use agent_infra_sdk::eval_contract::BindingTarget;
575    use clap::Parser;
576
577    fn parse(arguments: &[&str]) -> Command {
578        Cli::try_parse_from(arguments).expect("parses").command
579    }
580
581    #[test]
582    fn a_run_can_name_snapshots_or_use_the_binding() {
583        let explicit = parse(&[
584            "infra",
585            "eval",
586            "run",
587            "--agent",
588            "agent-1",
589            "--snapshot",
590            "builtin/tool-use@1",
591            "--subject-runtime",
592            "rt-1",
593        ]);
594        let Command::Eval {
595            command: EvalCommand::Run(args),
596        } = explicit
597        else {
598            panic!("expected a run command");
599        };
600        assert_eq!(args.snapshots, vec!["builtin/tool-use@1"]);
601        assert!(!args.use_binding);
602
603        let bound = parse(&[
604            "infra",
605            "eval",
606            "run",
607            "--agent",
608            "agent-1",
609            "--use-binding",
610        ]);
611        let Command::Eval {
612            command: EvalCommand::Run(args),
613        } = bound
614        else {
615            panic!("expected a run command");
616        };
617        assert!(args.use_binding);
618    }
619
620    #[test]
621    fn a_target_must_be_exactly_one_of_agent_or_runtime() {
622        let neither = TargetArgs {
623            agent: None,
624            runtime: None,
625        };
626        assert!(neither.resolve().is_err());
627
628        let both = TargetArgs {
629            agent: Some("a".into()),
630            runtime: Some("r".into()),
631        };
632        assert!(both.resolve().is_err());
633
634        assert_eq!(
635            TargetArgs {
636                agent: Some("a".into()),
637                runtime: None,
638            }
639            .resolve()
640            .unwrap()
641            .id,
642            "a"
643        );
644    }
645
646    #[test]
647    fn a_bad_snapshot_reference_names_itself_in_the_error() {
648        let error = parse_ref("tool-use@1").unwrap_err().to_string();
649        assert!(error.contains("tool-use@1"), "{error}");
650    }
651
652    #[test]
653    fn an_unknown_capability_lists_the_accepted_values() {
654        let error = parse_capability("vibes").unwrap_err();
655        assert!(error.contains("tool_use"), "{error}");
656    }
657
658    #[test]
659    fn admin_commands_are_reachable() {
660        let overview = parse(&["infra", "eval", "admin", "overview"]);
661        assert!(matches!(
662            overview,
663            Command::Eval {
664                command: EvalCommand::Admin {
665                    command: EvalAdminCommand::Overview
666                }
667            }
668        ));
669
670        let approve = parse(&[
671            "infra",
672            "eval",
673            "admin",
674            "approve-judge",
675            "judge-a@3",
676            "--labeled-samples",
677            "80",
678            "--human-agreement",
679            "0.94",
680        ]);
681        let Command::Eval {
682            command:
683                EvalCommand::Admin {
684                    command:
685                        EvalAdminCommand::ApproveJudge {
686                            labeled_samples, ..
687                        },
688                },
689        } = approve
690        else {
691            panic!("expected approve-judge");
692        };
693        assert_eq!(labeled_samples, 80);
694    }
695
696    #[test]
697    fn run_selection_is_exactly_one_of_binding_or_snapshots() {
698        assert!(validate_run_selection(true, &[]).is_ok());
699        assert!(validate_run_selection(false, &["tenant/suite@1".into()]).is_ok());
700        assert!(validate_run_selection(false, &[]).is_err());
701        assert!(validate_run_selection(true, &["tenant/suite@1".into()]).is_err());
702    }
703
704    #[test]
705    fn an_agent_target_supplies_the_subject_agent() {
706        let subject = resolve_subject(&BindingTarget::agent("agent-1"), None, Some("runtime-1"))
707            .unwrap()
708            .unwrap();
709        assert_eq!(subject.agent_id, "agent-1");
710        assert_eq!(subject.runtime_id, "runtime-1");
711
712        assert!(
713            resolve_subject(
714                &BindingTarget::runtime("runtime-1"),
715                None,
716                Some("runtime-1")
717            )
718            .is_err()
719        );
720    }
721}