Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions .github/workflows/skill-evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ jobs:
if [ "${{ github.event_name }}" = "pull_request" ]; then
OUT=$(python -m evals.select --base "origin/${{ github.base_ref }}")
else
# nightly / manual: run everything
OUT=$(python -m evals.select --changed evals/manifest.yaml)
# nightly (schedule) / manual (workflow_dispatch): full matrix —
# all skills (flyte-evals, incl. real tier) and the kind smoke.
OUT=$(python -m evals.select --all)
fi
echo "$OUT"
python - "$OUT" <<'PY' >> "$GITHUB_OUTPUT"
Expand All @@ -61,22 +62,22 @@ jobs:
with:
python-version: "3.12"
- run: pip install flyte>=2.5.0 pyyaml requests
- name: Configure Flyte auth
run: echo "auth via UNION_API_KEY secret"
env:
UNION_API_KEY: ${{ secrets.UNION_API_KEY }}
- name: Run evals on demo.hosted
env:
UNION_API_KEY: ${{ secrets.UNION_API_KEY }}
GLM_API_KEY: ${{ secrets.GLM_API_KEY }}
# The flyte v2 SDK authenticates to demo.hosted via an API key read from
# FLYTE_API_KEY, sourced from the repo secret DEMO_HOSTED_API_KEY. (The
# GLM judge key is supplied to task pods out-of-band via the Flyte
# secret `glm-api-key`, not through the CI environment.)
FLYTE_API_KEY: ${{ secrets.DEMO_HOSTED_API_KEY }}
TIERS: ${{ github.event_name == 'schedule' && '["static","trajectory","real"]' || '["static","trajectory"]' }}
# run_ci.py submits the workflow, waits for the run to reach a terminal
# phase, and exits non-zero unless it SUCCEEDED — unlike `flyte run`,
# which exits 0 as soon as the run is launched. The run itself fails when
# any scenario fails (see eval_wf.main), so this gates on eval results.
run: |
flyte --config evals/config/flyte.yaml run --copy-style loaded_modules \
evals/workflows/eval_wf.py main \
python -m evals.workflows.run_ci \
--skills '${{ needs.select.outputs.skills }}' \
--tiers "$TIERS"
# The workflow attaches the HTML scorecard to the run report; the run exits
# non-zero if any scenario fails (enforced inside aggregate/report).

# 3) Real kind-in-Docker smoke, only when a kind skill changed (privileged).
kind-smoke:
Expand Down
74 changes: 72 additions & 2 deletions evals/harness/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ArmResult:
judge: JudgeResult | None = None
exit_code: int = 0
error: str = ""
unavailable: bool = False # harness CLI not installed -> skip (not a failure)

@property
def checks_passed(self) -> bool:
Expand Down Expand Up @@ -68,17 +69,69 @@ def lift(self) -> float | None:
return None
return round(t.score - c.score, 4)

@property
def skipped(self) -> bool:
"""No usable harness ran this scenario (agent CLI not installed)."""
t = self.arms.get("treatment")
return bool(t and t.unavailable)

@property
def errored(self) -> bool:
"""The treatment harness ran but crashed — a broken eval, distinct from
a skill regression (and distinct from a merely-unavailable harness)."""
t = self.arms.get("treatment")
return bool(t and t.error and not t.unavailable)

@property
def status(self) -> str:
"""One of: skipped | error | scored."""
if self.skipped:
return "skipped"
if self.errored:
return "error"
return "scored"

@property
def score(self) -> float | None:
"""Treatment rating in [0,1] — the tracked signal — or None if not scored."""
if self.status != "scored":
return None
t = self.arms.get("treatment")
return t.score if t else None

@property
def checks_ok(self) -> bool:
"""Whether the treatment arm's deterministic checks passed. These are the
reliable, non-LLM signal CI gates on."""
t = self.arms.get("treatment")
return bool(t and t.checks_passed)

@property
def is_regression(self) -> bool:
"""The CI hard-gate: a scored *static* scenario whose deterministic
SKILL.md lint failed. Static lint is the only non-stochastic signal —
trajectory/real arms run an LLM agent, so their failures lower the tracked
`score`/rating instead of hard-failing CI (which would flake per PR)."""
return self.status == "scored" and self.tier == "static" and not self.checks_ok

@property
def passed(self) -> bool:
"""Scored and the treatment arm fully passed (checks + judge). Retained
for the scorecard; gating uses is_regression / status instead."""
t = self.arms.get("treatment")
return bool(t and t.passed)
return self.status == "scored" and bool(t and t.passed)

def to_dict(self) -> dict:
return {
"scenario_id": self.scenario_id,
"skill": self.skill,
"tier": self.tier,
"harness": self.harness,
"status": self.status,
"score": self.score,
"checks_ok": self.checks_ok,
"is_regression": self.is_regression,
"skipped": self.skipped,
"passed": self.passed,
"lift": self.lift,
"arms": {
Expand All @@ -88,6 +141,7 @@ def to_dict(self) -> dict:
"checks_passed": r.checks_passed,
"exit_code": r.exit_code,
"error": r.error,
"unavailable": r.unavailable,
"judge": None if r.judge is None else {
"score": r.judge.score,
"passed": r.judge.passed,
Expand All @@ -104,6 +158,18 @@ def to_dict(self) -> dict:
}


def skipped_scenario(scenario: Scenario, harness: str) -> ScenarioResult:
"""A ScenarioResult marked skipped because `harness` isn't installed. Built
without spawning work — the caller filters unavailable harnesses up front so
they still appear on the scorecard without burning a task per skip."""
res = ScenarioResult(scenario.id, scenario.skill, scenario.tier, harness=harness)
for arm in scenario.arms():
res.arms[arm] = ArmResult(
arm=arm, unavailable=True, error=f"{harness} CLI not available",
)
return res


def evaluate_static(scenario: Scenario) -> ScenarioResult:
skill_dir = REPO_ROOT / "plugins" / "flyte" / "skills" / scenario.skill
results = lint_skill(skill_dir)
Expand All @@ -122,7 +188,11 @@ def evaluate_scenario(scenario: Scenario, harness: str, glm: GLMConfig) -> Scena

for arm in scenario.arms():
if not runner.is_available():
res.arms[arm] = ArmResult(arm=arm, error=f"{harness} CLI not available")
# Harness CLI isn't installed — record as unavailable so the scenario
# is SKIPPED (excluded from rating and gating), not counted as a fail.
res.arms[arm] = ArmResult(
arm=arm, unavailable=True, error=f"{harness} CLI not available",
)
continue
try:
with make_sandbox(tier=scenario.tier, glm=glm) as sb:
Expand Down
141 changes: 128 additions & 13 deletions evals/kind_smoke/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,140 @@ nodes:
EOF

echo "==> in-cluster minio + postgres (throwaway deps for the smoke test)"
# Plain pinned manifests with official upstream images instead of the Bitnami
# Helm charts: Bitnami deprecated its free image catalog (Aug 2025), so those
# charts now pull rolling ':latest' tags from a shrinking registry, which is
# slow/flaky on 2-CPU CI runners (minio was hitting 'context deadline
# exceeded'). These are throwaway deps — the skill itself uses external hosted
# Postgres + S3/R2 — so determinism matters more than fidelity here. The
# Postgres Service keeps the name 'pg-postgresql' the flyte-binary step wires to.
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f -
helm repo add bitnami https://charts.bitnami.com/bitnami >/dev/null
helm repo update >/dev/null
helm upgrade --install pg bitnami/postgresql -n "$NS" \
--set auth.postgresPassword=flyte --set auth.database=flyte --wait --timeout 5m
helm upgrade --install minio bitnami/minio -n "$NS" \
--set auth.rootUser=minio --set auth.rootPassword=miniostorage --wait --timeout 5m
kubectl apply -n "$NS" -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: pg
labels: { app: pg }
spec:
replicas: 1
selector: { matchLabels: { app: pg } }
template:
metadata:
labels: { app: pg }
spec:
containers:
- name: postgres
image: postgres:16-alpine
env:
- { name: POSTGRES_PASSWORD, value: flyte }
- { name: POSTGRES_DB, value: flyte }
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
ports: [{ containerPort: 5432 }]
readinessProbe:
exec: { command: ["pg_isready", "-U", "postgres"] }
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- { name: data, mountPath: /var/lib/postgresql/data }
volumes:
- { name: data, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata:
name: pg-postgresql
spec:
selector: { app: pg }
ports: [{ port: 5432, targetPort: 5432 }]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
labels: { app: minio }
spec:
replicas: 1
selector: { matchLabels: { app: minio } }
template:
metadata:
labels: { app: minio }
spec:
containers:
- name: minio
image: minio/minio:RELEASE.2025-04-08T15-41-24Z
args: ["server", "/data", "--console-address", ":9001"]
env:
- { name: MINIO_ROOT_USER, value: minio }
- { name: MINIO_ROOT_PASSWORD, value: miniostorage }
ports: [{ containerPort: 9000 }, { containerPort: 9001 }]
readinessProbe:
httpGet: { path: /minio/health/ready, port: 9000 }
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- { name: data, mountPath: /data }
volumes:
- { name: data, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata:
name: minio
spec:
selector: { app: minio }
ports:
- { name: api, port: 9000, targetPort: 9000 }
- { name: console, port: 9001, targetPort: 9001 }
EOF
kubectl rollout status -n "$NS" deploy/pg --timeout=5m
kubectl rollout status -n "$NS" deploy/minio --timeout=5m

echo "==> create the object-store bucket in minio (the chart expects it to exist)"
# The flyte-binary chart does not create the metadata/userdata bucket — with a
# real S3/R2 backend the user pre-creates it, so mirror that for the throwaway
# minio here, otherwise the control plane can't reach its bucket.
kubectl run mc --rm -i --restart=Never -n "$NS" --image=minio/mc:latest --command -- \
sh -c "mc alias set m http://minio.${NS}.svc.cluster.local:9000 minio miniostorage && mc mb -p m/flyte"

echo "==> install flyte-binary (per deploy-flyte-kind flyte-binary step)"
helm repo add flyteorg https://flyteorg.github.io/flyte >/dev/null
helm repo update >/dev/null
# NOTE: values wiring (db + storage endpoints) mirrors the skill; kept minimal here.
helm repo update flyteorg >/dev/null
# Values wiring mirrors the skill's values-local.yaml. flyte-binary is v2, whose
# schema nests db under configuration.database.postgres.* and storage creds under
# configuration.storage.providerConfig.s3.* — the flat v1 keys are silently
# ignored, which leaves the DB host at its 127.0.0.1 default and hangs the
# wait-for-db init container forever. Point db at the in-cluster pg and storage
# at the in-cluster minio.
cat > "${TMPDIR:-/tmp}/fb-values.yaml" <<EOF
configuration:
database:
postgres:
host: pg-postgresql.${NS}.svc.cluster.local
port: 5432
dbname: flyte
username: postgres
password: flyte
options: "sslmode=disable"
storage:
metadataContainer: flyte
userDataContainer: flyte
provider: s3
providerConfig:
s3:
region: us-east-1
disableSSL: true
v2Signing: true
endpoint: http://minio.${NS}.svc.cluster.local:9000
authType: accesskey
accessKey: minio
secretKey: miniostorage
ingress:
create: false
EOF
helm upgrade --install flyte-binary flyteorg/flyte-binary -n "$NS" \
--set configuration.database.host="pg-postgresql.${NS}.svc.cluster.local" \
--set configuration.database.password=flyte \
--set configuration.storage.provider=s3 \
--wait --timeout 10m || true
-f "${TMPDIR:-/tmp}/fb-values.yaml" --wait --timeout 10m

echo "==> assert flyte-binary pod is present"
echo "==> assert flyte-binary pod is Ready"
kubectl get pods -n "$NS"
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=flyte-binary \
-n "$NS" --timeout=5m
Expand Down
Loading
Loading