diff --git a/.ci/harness.cloudbuild.yaml b/.ci/harness.cloudbuild.yaml new file mode 100644 index 00000000..1bc9b281 --- /dev/null +++ b/.ci/harness.cloudbuild.yaml @@ -0,0 +1,99 @@ +steps: + - id: pull-cache + name: 'gcr.io/cloud-builders/docker' + entrypoint: 'bash' + args: ['-c', 'docker pull us-central1-docker.pkg.dev/$PROJECT_ID/evalbench/evalbench-harness-ci:cache || exit 0'] + + - id: build-image + name: 'gcr.io/cloud-builders/docker' + waitFor: ['pull-cache'] + args: ['build', + '--cache-from', 'us-central1-docker.pkg.dev/$PROJECT_ID/evalbench/evalbench-harness-ci:cache', + '-t', 'evalbench-harness-ci', + '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/evalbench/evalbench-harness-ci:cache', + '-f', 'evalbench_service/Dockerfile', '.'] + + # Pushed here rather than via top-level `images:`, which only runs when the + # whole build succeeds -- the failing builds would never warm the cache. + - id: push-cache + name: 'gcr.io/cloud-builders/docker' + waitFor: ['build-image'] + allowFailure: true + args: ['push', 'us-central1-docker.pkg.dev/$PROJECT_ID/evalbench/evalbench-harness-ci:cache'] + + - id: fetch-sa-key + name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: 'bash' + waitFor: ['-'] + args: + - '-c' + - | + mkdir -p /etc/evalbench-sa-key + gcloud secrets versions access latest --secret="evalbench-sa-key" --project="$_EVAL_PROJECT" > /etc/evalbench-sa-key/key.json + volumes: + - name: 'sa_key_volume' + path: '/etc/evalbench-sa-key' + + - id: agy-cli + name: 'evalbench-harness-ci' + dir: '/evalbench' + waitFor: ['build-image', 'fetch-sa-key'] + allowFailure: true + entrypoint: 'bash' + args: ['-c', 'CI_HARNESS=agy_cli uv run --no-sync evalbench/evalbench.py --experiment_config=.ci/run_config.yaml'] + env: &harness_env + - 'EVAL_GCP_PROJECT_ID=${_EVAL_PROJECT}' + - 'EVAL_GCP_PROJECT_REGION=global' + - 'CI_CSQL_INSTANCE=${_CI_CSQL_INSTANCE}' + - 'UV_CACHE_DIR=/tmp/uv-cache' + volumes: + - name: 'eval_results' + path: '/evalbench/results' + - name: 'sa_key_volume' + path: '/etc/evalbench-sa-key' + + - id: claude-code + name: 'evalbench-harness-ci' + dir: '/evalbench' + waitFor: ['build-image'] + allowFailure: true + entrypoint: 'bash' + args: ['-c', 'CI_HARNESS=claude_code uv run --no-sync evalbench/evalbench.py --experiment_config=.ci/run_config.yaml'] + env: *harness_env + volumes: &results_volume + - name: 'eval_results' + path: '/evalbench/results' + + - id: codex-cli + name: 'evalbench-harness-ci' + dir: '/evalbench' + waitFor: ['build-image'] + allowFailure: true + entrypoint: 'bash' + args: ['-c', 'CI_HARNESS=codex_cli uv run --no-sync evalbench/evalbench.py --experiment_config=.ci/run_config.yaml'] + env: *harness_env + volumes: *results_volume + + - id: gemini-cli + name: 'evalbench-harness-ci' + dir: '/evalbench' + waitFor: ['build-image'] + allowFailure: true + entrypoint: 'bash' + args: ['-c', 'CI_HARNESS=gemini_cli uv run --no-sync evalbench/evalbench.py --experiment_config=.ci/run_config.yaml'] + env: *harness_env + volumes: *results_volume + + - id: verify-harnesses + name: 'evalbench-harness-ci' + dir: '/evalbench' + waitFor: ['agy-cli', 'claude-code', 'codex-cli', 'gemini-cli'] + args: ['uv', 'run', '--no-sync', 'python3', '.ci/verify_harness.py'] + volumes: *results_volume + +timeout: '3600s' + +options: + logging: CLOUD_LOGGING_ONLY + # Sized for the four concurrent harness steps, not for the build. + machineType: 'E2_HIGHCPU_8' \ No newline at end of file diff --git a/.ci/harness_smoke.evalset.json b/.ci/harness_smoke.evalset.json new file mode 100644 index 00000000..2a976d19 --- /dev/null +++ b/.ci/harness_smoke.evalset.json @@ -0,0 +1,31 @@ +{ + "scenarios": [ + { + "id": "ci-csql-list-and-get", + "starting_prompt": "list all instances in project ${EVAL_GCP_PROJECT_ID}", + "conversation_plan": "The user wants to see every Cloud SQL instance in project ${EVAL_GCP_PROJECT_ID}, then check the health of one of them. Once the agent has listed the instances, the user asks for the current state of ${CI_CSQL_INSTANCE} and expects the agent to report whether it is RUNNABLE. The user accepts that answer and ends the conversation.", + "expected_trajectory": [ + "cloud-sql__list_instances", + "cloud-sql__get_instance" + ], + "env": { + "GOOGLE_CLOUD_PROJECT": "${EVAL_GCP_PROJECT_ID}" + }, + "kind": "tools", + "max_turns": 2 + }, + { + "id": "ci-csql-get-instance-not-found", + "starting_prompt": "Get the details for the Cloud SQL instance named 'ci-missing-instance-do-not-create' in project '${EVAL_GCP_PROJECT_ID}'.", + "conversation_plan": "The user wants details for an instance that does not exist. The agent should call get_instance once, receive a not-found error, and report to the user that the instance could not be found. The user accepts that answer and ends the conversation. The user must not ask the agent to list instances, search for similar names, or otherwise recover.", + "expected_trajectory": [ + "cloud-sql__get_instance" + ], + "env": { + "GOOGLE_CLOUD_PROJECT": "${EVAL_GCP_PROJECT_ID}" + }, + "kind": "tools", + "max_turns": 2 + } + ] +} diff --git a/.ci/model_configs/agy_cli_model.yaml b/.ci/model_configs/agy_cli_model.yaml new file mode 100644 index 00000000..1aba462f --- /dev/null +++ b/.ci/model_configs/agy_cli_model.yaml @@ -0,0 +1,20 @@ +generator: agy_cli +# ADC does not authorize the Pro (High) tier. +model: "Gemini 3.1 Pro (Low)" +timeout: "20m" + +env: + GOOGLE_CLOUD_PROJECT: !ENV ${EVAL_GCP_PROJECT_ID} + GOOGLE_CLOUD_LOCATION: "global" + GOOGLE_GENAI_USE_VERTEXAI: "true" + +setup: + mcp_servers: + "cloud-sql": + httpUrl: "https://sqladmin.googleapis.com/mcp" + authProviderType: google_credentials + oauth: + scopes: + - https://www.googleapis.com/auth/cloud-platform + headers: + X-Goog-User-Project: !ENV ${EVAL_GCP_PROJECT_ID} diff --git a/.ci/model_configs/claude_code_model.yaml b/.ci/model_configs/claude_code_model.yaml new file mode 100644 index 00000000..4d4350e3 --- /dev/null +++ b/.ci/model_configs/claude_code_model.yaml @@ -0,0 +1,22 @@ +claude_code_version: "@anthropic-ai/claude-code@latest" +generator: claude_code +model: "claude-opus-5" + +use_vertex: true +vertex_project_id: !ENV ${EVAL_GCP_PROJECT_ID} +vertex_region: "global" + +env: + GOOGLE_CLOUD_PROJECT: !ENV ${EVAL_GCP_PROJECT_ID} + GOOGLE_CLOUD_LOCATION: "us-central1" + +setup: + mcp_servers: + "cloud-sql": + httpUrl: "https://sqladmin.googleapis.com/mcp" + authProviderType: google_credentials + oauth: + scopes: + - https://www.googleapis.com/auth/cloud-platform + headers: + X-Goog-User-Project: !ENV ${EVAL_GCP_PROJECT_ID} diff --git a/.ci/model_configs/codex_cli_model.yaml b/.ci/model_configs/codex_cli_model.yaml new file mode 100644 index 00000000..0061cfb9 --- /dev/null +++ b/.ci/model_configs/codex_cli_model.yaml @@ -0,0 +1,23 @@ +codex_cli_version: "@openai/codex@latest" +generator: codex_cli +model: "gpt-5.5" + +openai_api_key_secret: !ENV projects/${EVAL_GCP_PROJECT_ID}/secrets/openai-api-key/versions/2 + +# Codex reports token counts but no cost; these rates turn them into cost_usd. +pricing: + input_per_million_usd: 1.25 + cached_input_per_million_usd: 0.125 + output_per_million_usd: 10.0 + +env: + GOOGLE_CLOUD_PROJECT: !ENV ${EVAL_GCP_PROJECT_ID} + GOOGLE_CLOUD_LOCATION: "us-central1" + +setup: + mcp_servers: + "cloud-sql": + httpUrl: "https://sqladmin.googleapis.com/mcp" + authProviderType: google_credentials + headers: + X-Goog-User-Project: !ENV ${EVAL_GCP_PROJECT_ID} diff --git a/.ci/model_configs/gemini_cli_model.yaml b/.ci/model_configs/gemini_cli_model.yaml new file mode 100644 index 00000000..67bd122e --- /dev/null +++ b/.ci/model_configs/gemini_cli_model.yaml @@ -0,0 +1,19 @@ +gemini_cli_version: "@google/gemini-cli@latest" +generator: gemini_cli + +env: + GOOGLE_CLOUD_PROJECT: !ENV ${EVAL_GCP_PROJECT_ID} + GOOGLE_CLOUD_LOCATION: "global" + GOOGLE_GENAI_USE_VERTEXAI: "true" + GEMINI_CLI_TRUST_WORKSPACE: "true" + +setup: + mcp_servers: + "cloud-sql": + httpUrl: "https://sqladmin.googleapis.com/mcp" + authProviderType: google_credentials + oauth: + scopes: + - https://www.googleapis.com/auth/cloud-platform + headers: + X-Goog-User-Project: !ENV ${EVAL_GCP_PROJECT_ID} diff --git a/.ci/run_config.yaml b/.ci/run_config.yaml new file mode 100644 index 00000000..584092c6 --- /dev/null +++ b/.ci/run_config.yaml @@ -0,0 +1,28 @@ +# Shared by all four harnesses. CI_HARNESS selects which one runs, so every +# harness is scored identically and stays comparable. +dataset_config: .ci/harness_smoke.evalset.json +dataset_format: agent-format + +orchestrator: agent +model_config: !ENV .ci/model_configs/${CI_HARNESS}_model.yaml +simulated_user_model_config: datasets/model_configs/gemini_3.1_pro_model.yaml + +scorers: + trajectory_matcher: {} + turn_count: {} + agent_steps: {} + end_to_end_latency: {} + tool_call_latency: {} + token_consumption: {} + tokens_processed: {} + effective_billed_tokens: {} + goal_completion: + model_config: datasets/model_configs/gemini_3.1_pro_model.yaml + behavioral_metrics: + model_config: datasets/model_configs/gemini_3.1_pro_model.yaml + parameter_analysis: + model_config: datasets/model_configs/gemini_3.1_pro_model.yaml + +reporting: + csv: + output_directory: !ENV results/ci/${CI_HARNESS} diff --git a/.ci/verify_harness.py b/.ci/verify_harness.py new file mode 100644 index 00000000..2d464e72 --- /dev/null +++ b/.ci/verify_harness.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Gates the harness smoke build on two tiers of check. + +evalbench.eval() exits 0 whenever a run completes, so the exit code alone +cannot gate CI. + +Tier 1 (non-zero): telemetry scorers and trajectory_matcher must report more +than 0. They swallow parse failures and return 0.0 with an explanation rather +than raising, so a plain liveness check passes even when a CLI renames a token +field -- the exact drift this build exists to catch. Every scenario requires at +least one tool call, so these can never legitimately be 0. + +Tier 2 (liveness) covers the LLM judges without ever gating on their verdict, +which would make the build flaky. + +trajectory_matcher scores Jaccard overlap, so > 0 means at least one expected +tool was called -- which is what catches a harness that silently shells out +instead of reaching for MCP. +""" +import csv +import json +import math +import os +import sys + +from pyaml_env import parse_config + +HARNESSES = ["agy_cli", "claude_code", "codex_cli", "gemini_cli"] +RUN_CONFIG = ".ci/run_config.yaml" +EVALSET = ".ci/harness_smoke.evalset.json" +POSITIVE = { + "trajectory_matcher", + "turn_count", + "agent_steps", + "end_to_end_latency", + "tool_call_latency", + "token_consumption", + "tokens_processed", + "effective_billed_tokens", +} + + +def expected_scenario_ids(): + with open(EVALSET) as f: + return sorted(s["id"] for s in json.load(f)["scenarios"]) + + +def run_config(harness): + # The run config resolves ${CI_HARNESS} into the model config and output + # paths, so it has to be re-parsed per harness. + os.environ["CI_HARNESS"] = harness + return parse_config(RUN_CONFIG) + + +def latest_job_dir(output_dir): + if not os.path.isdir(output_dir): + return None + jobs = [os.path.join(output_dir, d) for d in os.listdir(output_dir) + if os.path.isdir(os.path.join(output_dir, d))] + return max(jobs, key=os.path.getmtime) if jobs else None + + +def load_rows(job_dir): + """Returns {(comparator, scenario_id): (score_or_None, error)}.""" + path = os.path.join(job_dir, "scores.csv") + if not os.path.exists(path): + return None + rows = {} + with open(path, newline="") as f: + for row in csv.DictReader(f): + try: + score = float(row["score"]) + # nan <= 0 is False, so an unguarded nan would clear the + # Tier 1 gate. + if not math.isfinite(score): + score = None + except (KeyError, TypeError, ValueError): + score = None + rows[(row.get("comparator"), row.get("id"))] = ( + score, (row.get("comparison_error") or "").strip() + ) + return rows + + +def check(harness, scenario_ids): + config = run_config(harness) + scorers = sorted(config.get("scorers") or {}) + output_dir = config["reporting"]["csv"]["output_directory"] + + job_dir = latest_job_dir(output_dir) + if job_dir is None: + return [f"no run output under {output_dir}"], 0, scorers + + rows = load_rows(job_dir) + if rows is None: + return [f"no scores.csv in {job_dir}"], 0, scorers + + problems = [] + checked = 0 + for scorer in scorers: + for sid in scenario_ids: + entry = rows.get((scorer, sid)) + if entry is None: + problems.append(f"{scorer}: no row for {sid}") + continue + score, error = entry + checked += 1 + if error: + problems.append(f"{scorer}: errored on {sid} -- {error[:120]}") + elif score is None: + problems.append(f"{scorer}: non-numeric score for {sid}") + elif scorer in POSITIVE and score <= 0: + reason = ( + "none of the expected tools were called" + if scorer == "trajectory_matcher" + else "the agent called no tools, or the scorer could not " + "read the harness output" + ) + problems.append(f"{scorer}: {sid} reported 0 -- {reason}") + return problems, checked, scorers + + +def main(): + scenario_ids = expected_scenario_ids() + print(f"Scenarios: {len(scenario_ids)} | Harnesses: {len(HARNESSES)}") + print(f"Must be > 0: {', '.join(sorted(POSITIVE))}") + print("All other scorers are liveness-checked (ran, no error, " + "numeric score)\n") + + failed = [] + for harness in HARNESSES: + problems, checked, scorers = check(harness, scenario_ids) + if problems: + failed.append(harness) + print(f" [FAIL] {harness}") + for p in problems: + print(f" {p}") + else: + print(f" [PASS] {harness:<12} " + f"{checked} checks across {len(scorers)} scorers") + + if failed: + print(f"\nFAILED: {', '.join(failed)}") + return 1 + print("\nAll harnesses passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())