1use 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
24const POLL_INTERVAL: Duration = Duration::from_secs(3);
26
27#[derive(Debug, Subcommand)]
28pub enum EvalCommand {
29 Snapshots(SnapshotFilter),
31 Snapshot { reference: String },
33 Validate(JsonInput),
35 Publish(JsonInput),
37 Bind(BindArgs),
39 Bindings(PageArgs),
41 Unbind(TargetArgs),
43 Run(RunArgs),
45 Operation { evaluate_task_id: String },
47 Cases {
49 #[arg(long)]
50 run: String,
51 #[command(flatten)]
52 page: PageArgs,
53 },
54 Report {
56 #[arg(long)]
57 run: String,
58 #[arg(long)]
59 revision: Option<u32>,
60 },
61 Gate(GateArgs),
63 Cancel {
65 #[arg(long)]
66 run: String,
67 },
68 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 #[arg(long, group = "target")]
99 pub agent: Option<String>,
100 #[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 #[arg(long = "snapshot", required = true)]
121 pub snapshots: Vec<String>,
122 #[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 #[arg(long)]
133 pub use_binding: bool,
134 #[arg(long = "snapshot")]
135 pub snapshots: Vec<String>,
136 #[arg(long)]
138 pub subject_runtime: Option<String>,
139 #[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 #[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(JsonInput),
196 Publish {
197 reference: String,
198 },
199 Deprecate {
200 reference: String,
201 #[arg(long)]
202 replacement: Option<String>,
203 },
204 Export,
206 Import(JsonInput),
208 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 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 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 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}