Repository navigation
2634 lines (2523 loc) · 160 KB
/
Copy patheval_flagship.yml
File metadata and controls
2634 lines (2523 loc) · 160 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
# The flagship agent's eval gate: every scenario category, scored against the
# agent users actually meet.
#
# Unit tests cover code paths, not model behavior, so this is the only thing that
# catches a behavioral regression. It runs on release and RC tags (see `on:`);
# CLAUDE.md says how an LLM-affecting PR relies on the next RC's run.
#
# Runner-only by construction. `gaia eval agent` drives the real GAIA UI backend
# against a real Lemonade Server on AMD hardware; a GitHub-hosted runner has
# neither. Hence the self-hosted pool.
#
# WHY THIS RUNS IN PARALLEL LANES
# ------------------------------------------------------------------------------
# 82 scenarios on one serial Lemonade slot is ~5.8 h expected and 9.2 h at the
# observed ceiling, holding a shared single-slot pool for the whole time. That is
# not a PR gate. The categories are split into independent LANES (eval/ci_lanes.json)
# that run at the same time on the EPHEMERAL `devlab-dispatch` pool, where each job
# gets its own machine and therefore its own Lemonade.
#
# This does not weaken CLAUDE.md's "run evals serially" rule. That rule exists
# because two `gaia eval agent` processes sharing ONE Lemonade race-evict each
# other's model out of the single slot — it is per-BACKEND, not global. Separate
# machines have separate backends and cannot collide. Within a lane the categories
# still run serially in one foreach, which is the part the rule actually governs.
#
# Do NOT give the lanes a shared concurrency group. `lemonade-eval` serialises by
# group NAME regardless of which machine a job lands on, so a single shared group
# would silently put the lanes back in a queue and undo all of this. Each lane
# holds its own group; see the job-level `concurrency:` blocks below.
#
# WHAT GETS UPLOADED, AND WHY THAT CHANGED
# ------------------------------------------------------------------------------
# `devlab-dispatch` DESTROYS the machine when the job ends. Anything not uploaded
# is gone permanently — so the old "transcripts and logs stay on the runner,
# reproduce locally" posture would mean every failure this eval finds is
# unreadable. Each lane uploads its scorecards, per-scenario traces (transcript,
# judge reasoning, root cause), and the backend + Lemonade logs, on `always()`,
# because a crashed lane is exactly when they matter. Everything bound for the
# artifact is secret-scanned first: the artifact is PUBLIC and, unlike the job
# log, not masked by GitHub.
#
# THIS POOL IS WINDOWS. Every inline `run:` step below is PowerShell, and Python
# setup goes through ./.github/actions/setup-venv (uv, Windows-aware) — the
# lab-runner convention shared by test_email_agent_eval.yml and
# email_scorecard_refresh.yml. Do NOT reintroduce
# bash here: this workflow's first-ever execution died instantly on
# `source .venv/bin/activate` because GitHub ran it through powershell.EXE, and
# every other bash step in the job hit the same parser. Windows also breaks the
# text-processing assumptions bash invites — the eval prints an `Output:` line
# containing a backslash path, which bash test/`[ -f ]` cannot consume.
#
# WHY THIS REPORTS BY DEFAULT INSTEAD OF HARD-FAILING (`enforce`, default false)
# ---------------------------------------------------------------------------
# `gaia eval agent --compare BASELINE CURRENT` already exits 2 on any status /
# score / time regression and 0 otherwise (src/gaia/cli.py). Wiring that exit
# code straight to the step result would make this job PERMANENTLY RED, because
# some regressions here are EXPECTED and accepted: code-generation and
# data-analysis scenarios are dropping from a 35B MoE to a ~4B dense model. A
# gate that is always red gates nothing — people stop reading it.
#
# So the default posture is REPORT: every category is compared, the full diff is
# printed, the scorecards are uploaded as an artifact, and a regression surfaces
# as a `::warning::` annotation. Make it blocking by dispatching with
# `enforce: true`; a tag run always reports.
#
# This mirrors how intentional regressions are handled elsewhere: the
# `--allow-regression` flag in src/gaia/eval/scorecard_gate.py prints a
# `::warning::` and returns 0 rather than pretending the regression did not
# happen, and test_email_agent_eval.yml ships its gates in report mode until the
# bars are trustworthy. Same idea, inverted default: report unless asked to
# enforce.
#
# REPORT MODE COVERS "WORSE" AND "DIDN'T MEASURE" ALIKE — VISIBLY. `--compare`
# scores only scenarios present on BOTH sides: scenarios that vanish from the
# current run land in an `only_in_baseline` bucket that is NOT part of its exit-2
# verdict (runner.py::compare_scorecards). Verified: dropping 6 of 7 rag_quality
# scenarios still exits 0. So a broken harness would otherwise sail through as "no
# regression". The integrity gate below is what refuses that: it fails if a
# baseline scenario is missing or any scenario produced no measurement
# (infra_error / errored / timeout / blocked / budget_exceeded / skipped).
#
# That gate used to ignore `enforce` and fail unconditionally, on the reasoning
# that report mode may soften "worse" but never "did not measure". The reasoning
# is sound and the outcome was not: the embedder cannot load on this runner
# (#3016), which is an environment defect no PR can fix, so the gate was red on
# every PR touching the path filter — including seven whose diffs never went near
# the eval — while this same job printed "report mode ... build stays green". A
# check that is always red gates nothing, and one that advertises non-blocking
# while blocking is worse than either honest posture.
#
# So `enforce` now decides the EXIT CODE for an incomplete run as well as a worse
# one. It never decides VISIBILITY: in report mode every finding is still a
# `::warning::` annotation AND a block in the job summary, so an incomplete run
# cannot be mistaken for a complete one. `enforce: true` restores blocking for
# both.
#
# ONE EXCEPTION, AND IT IS ABOUT BLAME, NOT CONVENIENCE: when the RAG embedder
# will not load on the runner at all (#3016, EMBEDDER_OK=false), rag_quality and
# context_retention warn instead of failing. Every other non-measurement says
# something about the PR under test; #3016 says only that this SoC cannot serve
# the embedder for ANY commit, so failing on it made the gate permanently red
# and unread. Nothing else is softened — tool_selection's own non-measurements
# still fail. Remove the exception when #3016 closes. See the gate step below.
#
# TIME REGRESSIONS. `--compare` folds a >2x per-scenario wall-clock delta into
# the same exit 2 as a quality regression. The baseline was captured on this
# pool, so a time regression is comparable, but it comes from one run: re-run a
# lone time-only flip before treating it as a defect.
#
# SEQUENCING. `--compare` only DIFFS two scorecards — it does NOT run an eval.
# So each category is evaluated first, the ABSOLUTE run directory is parsed from
# the eval's own `Output: <run-dir>` line, and `<run-dir>/scorecard.json` is then
# compared against the committed Gemma baseline. Never glob by mtime — a fresh
# clone stamps every file with the checkout time.
#
# PREREQUISITES ON THE RUNNER. Lemonade is NOT assumed to be already running:
# install-lemonade pins the runner to the expected LEMONADE_VERSION and
# ensure-lemonade-running.ps1 starts it as a persistent Scheduled Task and warms
# the model — exactly what test_email_agent_eval.yml and
# email_scorecard_refresh.yml do. (An earlier revision of this file claimed those
# two workflows assume a persistent server and start nothing; they do not, and
# that claim is what left this job with no way to bring Lemonade up.) The GAIA UI
# backend is separate and NOT persistent, so this workflow starts it itself — in
# the SAME shell session as the evals, because a backgrounded child does not
# survive a GitHub Actions step boundary.
#
# Lemonade must serve TWO models, not one. ensure-lemonade-running.ps1 warms
# only the chat model, but rag_quality and context_retention answer out of RAG
# chunks, so the embedder is just as load-bearing — and when it cannot start,
# those categories collapse to ~2/10 non-answers that read as a model
# regression. The preflight therefore re-registers, re-pulls and then makes a
# real /embeddings call through tests/ci_lemonade_check.py — repairing the one
# cause CI can repair (a stale registration missing the embeddings label) and
# failing in seconds on the ones it cannot, instead of spending three and a half
# hours measuring nothing.
#
# The third prerequisite is the Claude Code CLI, which is easy to miss because
# ANTHROPIC_API_KEY looks like the whole story: `gaia eval agent` DRIVES each
# scenario by shelling out to `claude -p` with an MCP config, and its preflight
# refuses to start without it on PATH. Installed below via npm, as
# docs/guides/eval-ci.mdx prescribes.
#
# WHAT TRIGGERS IT, AND HOW MUCH RUNS. Tiers, from eval/ci_tiers.json via
# util/eval_tiers.py (#5493 section 6.6):
#
# RC tag (v*-rc*) smoke: 30 stable scenarios, 13 protected, 3 machines
# RC tag with [full eval] full: every lane minus the weekly backlog's
# in its annotation leaves_full scenarios. Nominate the RC you
# will promote; [skip eval] still skips
# final tag (vX.Y.Z) verify: nothing re-runs (the release train
# promotes the measured RC, #5492); verify-final
# checks a full sweep measured this commit or an RC
# weekly (Sun 06:17 UTC) backlog: always-fail and high-flip scenarios on 3
# machines, with a rolling trend artifact. Skipped
# while any other run of this workflow holds the pool
# dispatch `tier` (default smoke), or a targeted selection
# through `categories` / `scenarios`
#
# THE MODEL UNDER TEST on this pool is Qwen3.6-35B-A3B, what `gaia init`
# installs on a 64 GB+ Strix Halo, pinned in every category's GAIA home and
# checked per scenario (--agent-model). It compares only against the Qwen
# baseline (tests/fixtures/eval_baselines/gaia-flagship-qwen); until one is
# committed every category reports "no baseline yet". Gemma-4-E4B is scored on
# the Strix Point pool. A dispatch can still pick `model: Gemma-4-E4B-it-GGUF`
# here, which compares against the Gemma-on-Halo baseline in gaia-flagship/ -
# the way a targeted re-run joins an earlier Gemma tag run.
#
# FORKS AND DRAFTS. The job-level `if` guards below still skip fork and draft
# PRs, in case a `pull_request` trigger is ever added back: a fork PR could not
# pass (ANTHROPIC_API_KEY is not exposed to it) and must not put unreviewed code
# on the lab pool. If the key is ever missing, the preflight fails with
# instructions instead of running a judge-less eval.
name: Agent Eval — flagship
on:
# Release and release-candidate tags (v0.24.1, v0.25.0-rc14), one weekly
# backlog run, and manual dispatch. Not per-PR and not nightly: the Strix Halo
# pool is too scarce for either - per-PR runs queued for hours and had to be
# cancelled. An LLM-affecting PR names the categories to watch; the next RC's
# run covers it. A tag run checks out the tagged commit, compares it with the
# committed baseline in report mode, and files an issue when it does not pass
# (release-escalation below). `v*.*.*` already matches the -rc tags; the
# second pattern says so out loud. Agent-package tags (agent-pkg-*) match
# neither.
push:
tags:
- 'v*.*.*'
- 'v*.*.*-rc*'
# The weekly backlog lane (maintainer-approved). Sunday 06:17 UTC is the
# quietest hour on the pool; the minute is off the hour so it does not land
# with every other cron. Owner and README: eval/ci_tiers.json, docs/reference/eval.mdx.
schedule:
- cron: '17 6 * * 0'
workflow_dispatch:
inputs:
enforce:
description: 'Fail the build on a regression (false = report as ::warning:: only)'
required: false
type: boolean
default: false
eval_model:
description: 'Driver + judge model for the eval harness. Blank = the model the flagship baseline was captured under (tests/fixtures/eval_baselines/gaia-flagship/meta.json)'
required: false
default: ''
deep_diagnostics:
description: 'Run the full embedder-failure ladder (purges the model from disk, unloads all models). Off by default - it costs ~7 extra min and disturbs other jobs on the box.'
required: false
type: boolean
default: false
task_suite:
description: 'Space-separated suites from eval/tasks/tasks.json (a tag run uses everyday therock; adversarial is the traps; full is everything)'
required: false
default: 'core'
tier:
description: 'smoke (30 scenarios, 3 machines), full (the promotion sweep, ~13 machines), or backlog (the weekly lane). Ignored when categories or scenarios is set.'
required: false
type: choice
options: [smoke, full, backlog]
default: smoke
# Qwen is what a 64 GB+ Strix Halo runs. Gemma compares against the
# Gemma-on-Halo baseline: use it to re-run part of a Gemma tag run.
model:
description: 'Local model under test. Each compares only against its own baseline (eval/ci_tiers.json).'
required: false
type: choice
options: [Qwen3.6-35B-A3B-GGUF, Gemma-4-E4B-it-GGUF]
default: Qwen3.6-35B-A3B-GGUF
# Targeted runs. Blank both to run the `tier`. Validated by
# util/eval_lane_matrix.py before any lane takes a lab machine.
categories:
description: 'Comma-separated categories to run (blank = the tier). Add "outcomes" to also run the outcomes job, which a targeted run otherwise skips.'
required: false
default: ''
scenarios:
description: 'Comma-separated scenario ids to run (blank = every scenario in the selected categories). Only lanes holding them get a machine.'
required: false
default: ''
# NO WORKFLOW-LEVEL CONCURRENCY, deliberately. A group declared here is held by
# the whole run, so every lane would wait on the one the first lane took and the
# split would buy nothing — the same shape as the phantom-job bug this repo has
# already paid for once (a caller holding the group its callee needed; see
# test_email_agent_eval.yml). Each lane declares its own group at job level,
# where `matrix.lane` is in scope.
#
# `lemonade-eval` is gone from this file on purpose and must not come back. It
# serialises by NAME across machines, which is right for the persistent pool the
# email eval still runs on and wrong for the ephemeral one these lanes use.
permissions:
contents: read
env:
# No BASELINE_DIR here: it depends on the model under test, so the lanes job
# resolves it from eval/ci_tiers.json (`baselines`) and each job sets it.
# A baseline holds one scorecard per category, copied unedited from a real run
# on this pool; its meta.json names the model, the run and the eval_model every
# job runs under unless a dispatch overrides it. Never edit a number in one.
#
# THE LANE MAP IS THE SOURCE OF TRUTH: eval/ci_lanes.json. The `lanes` job below
# reads it, checks it, and hands each lane its own category list as
# EVAL_CATEGORIES at job level — which is what the four steps downstream already
# consume, so they keep working unchanged and still read one list, not four.
#
# NEVER ADD web_system OR vision TO A LANE: they read the runner's clipboard,
# windows and screen into a scorecard that uploads as a PUBLIC artifact. They
# are in the map's `excluded` block, the `lanes` job re-checks it at runtime,
# and tests/unit/eval/test_ci_lanes.py fails the build if either appears.
EVAL_LANES_FILE: eval/ci_lanes.json
# What still means something when the RAG embedder will not load (#3016).
# Not the whole list above: for the categories left out, only a real run says
# whether they stay interpretable without retrieval, and guessing would put
# unmeasurable categories in a scorecard. `memory` needs no such run - 0 of
# its 25 scenarios carry a non-empty `setup.index_documents`, so the embedder
# is not what would stop them scoring, and leaving it out meant a degraded
# run measured 2 scenarios out of 82.
# Scenarios inside a listed category may still fail on their own - 3 of
# tool_selection's 5 do index documents. That is the posture on purpose: run
# the category, let the scenario fail, do not call the whole category
# unmeasurable.
# BEFORE WIDENING FURTHER, do the timeout arithmetic - but do it per LANE,
# not across this list. A degraded run only ever cuts a lane down to its own
# categories (the intersection in the run step), so the budget that can
# overflow is the lane's, and it is always smaller than the full run the job
# ceiling above was sized for. Widening is therefore free on time and costs
# only interpretability; what it must not do is add a category no run has
# shown to be measurable without retrieval.
# Everything in EVAL_CATEGORIES but not here is reported NOT MEASURED.
EVAL_CATEGORIES_NO_EMBEDDER: memory tool_selection
BACKEND_URL: http://127.0.0.1:4200
# This runner's home is C:\Windows\System32\config\systemprofile and the
# checkout is not under it, so without this every scenario carrying a
# `setup.index_documents` block 403s and scores as a failure (#3585).
# Declares the checkout's corpus, nothing wider.
GAIA_DOCUMENT_ROOTS: ${{ github.workspace }}\eval\corpus
# Only a dispatch can enforce; a tag run compares with the baseline and reports.
ENFORCE: ${{ github.event.inputs.enforce || 'false' }}
# EVAL_MODEL is not set here: each job reads it from $BASELINE_DIR/meta.json
# (eval_model), because `--model` is not a judge-only knob. runner.py passes it
# to `claude -p`, which DRIVES every scenario as well as scoring it, so the
# driver is part of the agent under test and must be the one the baseline was
# recorded under. It changes only with a re-captured baseline. #2859 moved
# every other Claude workflow to Opus 5. A dispatch input overrides it per run.
EVAL_MODEL_OVERRIDE: ${{ github.event.inputs.eval_model }}
# Deep ladder is opt-in. Its rungs purge the embedder from disk and unload every
# resident model, which is fine while investigating and wrong to repeat on every
# PR: the cause is known (#3016) and the box is shared, so the next job landing
# on it pays for a cold reload it did not ask for.
DEEP_DIAG: ${{ github.event.inputs.deep_diagnostics || 'false' }}
# Loopback services — bypass any runner proxy. This covers the PYTHON side
# (httpx/requests in the eval, the agents and the backend); PowerShell's
# Invoke-RestMethod ignores NO_PROXY and uses the system proxy instead.
NO_PROXY: 'localhost,127.0.0.1'
# Bare host (no /api/v1): LemonadeClient normalizes the suffix itself. Mirrors
# test_email_agent_eval.yml so the preflight probe and the agents agree.
LEMONADE_BASE_URL: 'http://127.0.0.1:13305'
# The eval prints box-drawing characters and em dashes; the runner's default
# console codepage is cp1252 and Python dies on the first one without this.
PYTHONIOENCODING: 'utf-8'
# Connector resolution blocks on the OS keyring in a headless runner, which
# hangs agent construction. Same guard as test_email_agent_eval.yml.
PYTHON_KEYRING_BACKEND: keyring.backends.null.Keyring
# The default for every lane; the memory and gaia_* lanes override all three at
# job level (util/eval_lane_matrix.py).
# Long-term memory surviving between categories makes a later category's score
# depend on an earlier one's run order (convention: test_chat_agent.yml,
# test_email_agent_eval.yml). The memory lane is the one place that state IS
# the subject, and it can opt back in safely because every category runs on
# its own fresh GAIA home (see the run step). Do not rely on the machine being
# wiped: the runner's ~/.gaia survived between runs and leaked memory (rc16).
#
# Do NOT flip these here. Workflow scope would re-enable memory for every lane
# and bring back the cross-category bleed this is guarding.
GAIA_MEMORY_DISABLED: '1'
# Both gate the memory surface the scenarios verify through, and both are read
# from the environment only: nothing in src/gaia/ sets them, despite two code
# comments that say the eval runner does. ADMIN gates memory_clear / the admin
# REST endpoints the runner's own memory preflight probes; MCP_ALWAYS gates
# whether the Agent UI MCP server registers the memory READ tools at all.
# Without the second, a scenario's "VERIFY VIA MCP: memory_recall(...)" has no
# tool to call and the judge is left scoring the reply text alone.
GAIA_MEMORY_ADMIN: '0'
GAIA_MEMORY_MCP_ALWAYS: '0'
# Lets a scenario's setup.decline_commands script a user who says no
# (gaia_resilience); inert for every scenario that does not set it.
GAIA_EVAL_SCRIPTED_USER: '1'
jobs:
# An RC tag annotated with `[skip eval]` skips every lane below; a final
# release, a lightweight tag and a dispatch always run (util/eval_tag_gate.py).
# Then the tier (util/eval_tiers.py): RC smoke, [full eval] full sweep, final
# verify, weekly backlog, or a dispatch's choice. Hosted and seconds long, so
# the decision costs no lab-pool time.
eval-gate:
name: Eval gate (release tag)
runs-on: ubuntu-latest
permissions:
contents: read
# The weekly run lists this workflow's queued and running runs, to stay
# off the pool while a release eval is on it.
actions: read
outputs:
run: ${{ steps.gate.outputs.run }}
tier: ${{ steps.tier.outputs.tier }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Read the tag annotation
id: gate
run: python util/eval_tag_gate.py
- name: Pick the tier
id: tier
if: steps.gate.outputs.run == 'true'
env:
GH_TOKEN: ${{ github.token }}
EVAL_DISPATCH_TIER: ${{ github.event_name == 'workflow_dispatch' && inputs.tier || '' }}
EVAL_SELECT_CATEGORIES: ${{ github.event_name == 'workflow_dispatch' && inputs.categories || '' }}
EVAL_SELECT_SCENARIOS: ${{ github.event_name == 'workflow_dispatch' && inputs.scenarios || '' }}
run: python util/eval_tiers.py decide >> "$GITHUB_OUTPUT"
# Build the lane matrix, and refuse to build a bad one. Runs on a hosted runner
# in seconds, BEFORE anything reaches the scarce AMD pool, so a broken lane map
# costs nothing to discover.
#
# It checks the two things a matrix cannot check itself. A category that belongs
# to no lane is never passed to `--category`, so it simply stops being measured
# while every lane still reports green — an absence that looks exactly like a
# pass. And `vision` / `web_system` in a lane would publish the runner's screen
# and clipboard in a world-readable artifact. Both fail here rather than being
# left to a comment nobody re-reads.
lanes:
name: Resolve the lane matrix
needs: eval-gate
if: >-
!cancelled() && needs.eval-gate.result == 'success' &&
needs.eval-gate.outputs.run == 'true'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.build.outputs.matrix }}
has_lanes: ${{ steps.build.outputs.has_lanes }}
targeted: ${{ steps.build.outputs.targeted }}
outcomes: ${{ steps.build.outputs.outcomes }}
selection: ${{ steps.build.outputs.selection }}
tier: ${{ steps.build.outputs.tier }}
agent_model: ${{ steps.build.outputs.agent_model }}
baseline_dir: ${{ steps.build.outputs.baseline_dir }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Validate the lane map and tiers, and emit the matrix
id: build
# The rules (which lane gets memory, which gets the gaia_* fixtures,
# what may never be in a lane, what a dispatch may select) live in
# util/eval_lane_matrix.py; which scenarios each tier runs, on which
# machine, against which model's baseline, in util/eval_tiers.py and
# eval/ci_tiers.json. It re-runs the tier coverage check first, so a bad
# tier file fails here in seconds. Tests: tests/unit/eval/test_eval_lane_matrix.py,
# tests/unit/eval/test_eval_tiers.py. Inputs arrive through env, never
# inline in the script.
env:
EVAL_TIER: ${{ needs.eval-gate.outputs.tier }}
EVAL_AGENT_MODEL: ${{ github.event_name == 'workflow_dispatch' && inputs.model || '' }}
EVAL_SELECT_CATEGORIES: ${{ github.event_name == 'workflow_dispatch' && inputs.categories || '' }}
EVAL_SELECT_SCENARIOS: ${{ github.event_name == 'workflow_dispatch' && inputs.scenarios || '' }}
run: python util/eval_tiers.py matrix >> "$GITHUB_OUTPUT"
- name: Label a partial run in the summary
if: steps.build.outputs.targeted == 'true'
env:
SELECTION: ${{ steps.build.outputs.selection }}
TIER: ${{ steps.build.outputs.tier }}
CATEGORIES: ${{ inputs.categories }}
SCENARIOS: ${{ inputs.scenarios }}
run: |
{
echo "## ${SELECTION}"
echo ""
echo "This is NOT a full sweep. Only the selection below ran; every other scenario has no result in this run."
echo ""
if [ "${TIER}" = "targeted" ]; then
echo "- categories input: \`${CATEGORIES:-<blank>}\`"
echo "- scenarios input: \`${SCENARIOS:-<blank>}\`"
else
echo "- tier \`${TIER}\`: the scenarios and the reason each is in it are in \`eval/ci_tiers.json\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
# The flagship GaiaAgent on outcome-scored tasks (`gaia eval tasks`, docs:
# docs/reference/eval.mdx), served by GAIA's own embedded Lemonade. Three steps
# with separate environments: the agent runs with no credentials (it runs
# shell commands), Claude judges quality (and decides the question tasks) in
# one call with no tools, and the gate compares passes, tested changes,
# quality, honesty, tokens, steps and runtime with main's committed results in
# eval/tasks/expectations/<model>.<suite>.json. The gate always enforces: a
# missed expectation fails this job. A model with no committed expectations
# is reported, not failed, and a task the backend could not serve is "not
# measured", never a regression. Memory is off here, as it is outside the
# memory and gaia_* lanes.
#
# Which suites: a release or RC tag runs `everyday` (the 14 tasks the
# published model rankings cite) and `therock` (four real fixes in a
# 144K-line repo). A dispatch runs the suites it names (default `core`, the
# traps, ~7 tasks). Each suite is judged and gated on its own against
# eval/tasks/expectations/<model>.<suite>.json once they are committed (from
# `gaia eval tasks propose` over repeated TASK_MODEL runs on this pool); until
# then the gate reports only. A failing tag run files an issue naming the
# suite (release-escalation below).
#
# A lane in everything but the matrix. It stays its own job because its
# steps are a different shape - embedded Lemonade, `gaia eval tasks` - not
# because it is ordered against the others; it runs alongside them.
#
# Only the full sweep runs it, so the RC smoke tier stays on three machines.
# A targeted dispatch skips it unless its `categories` input names
# `outcomes`; the weekly backlog and a final tag never run it. The lanes job
# decides (`outcomes` output) for every event.
flagship-tasks:
name: Scenario eval - outcomes
needs: [eval-gate, lanes]
outputs:
failed_suites: ${{ steps.gate.outputs.failed }}
if: >-
!cancelled() && needs.eval-gate.result == 'success' &&
needs.eval-gate.outputs.run == 'true' && needs.lanes.result == 'success' &&
needs.lanes.outputs.outcomes == 'true' &&
(github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
(github.event.pull_request.draft == false ||
contains(github.event.pull_request.labels.*.name, 'ready_for_ci'))))
concurrency:
group: ${{ github.workflow }}-outcomes-${{ github.ref }}
cancel-in-progress: false
runs-on: [self-hosted, Windows, devlab-dispatch, strix-halo]
defaults:
run:
# Windows PowerShell 5.1 and pure-ASCII run bodies, for the reasons
# spelled out on the agent-eval job below.
shell: powershell
# At least the sum of the step limits below plus setup, or a job-level
# timeout cancels and skips the judge, gate and upload steps.
timeout-minutes: ${{ github.event_name != 'pull_request' && 420 || 200 }}
env:
# The venv's interpreter and CLI by full path. A bare `python` resolves
# through PATH, which a runner's own setup can put ahead of the venv.
VENV_PY: ${{ github.workspace }}\.venv\Scripts\python.exe
VENV_GAIA: ${{ github.workspace }}\.venv\Scripts\gaia.exe
TASK_SUITES: ${{ github.event.inputs.task_suite || (github.event_name == 'push' && 'everyday therock') || 'core' }}
# The local model the expectations were measured on. Changing it means
# measuring and committing new expectations for it first.
TASK_MODEL: Qwen3.6-35B-A3B-GGUF
TASK_JUDGE_MODEL: claude-opus-5-5
TASKS_OUT: eval-out\tasks
steps:
- name: Show which Claude account this job uses
run: echo "::notice title=Claude account::${{ vars.CLAUDE_ACCOUNT != 'secondary' && 'This job uses the primary Claude account.' || secrets.CLAUDE_CODE_OAUTH_TOKEN_SECONDARY != '' && 'This job uses the secondary Claude account.' || 'CLAUDE_ACCOUNT is secondary but CLAUDE_CODE_OAUTH_TOKEN_SECONDARY is not set, so this job uses the primary Claude account.' }}"
- name: Checkout
uses: actions/checkout@v7
- name: Setup Python Environment
id: venv
uses: ./.github/actions/setup-venv
with:
python-version: '3.12'
# The flagship ships as its own hub package and builds on ChatAgent.
install-package: '-e .[dev,eval,ui,api] -e hub/agents/chat/python -e hub/agents/gaia/python'
- name: Check the venv interpreter
env:
SETUP_VENV_PY: ${{ steps.venv.outputs.python-path }}
run: |
if ($env:SETUP_VENV_PY -ne $env:VENV_PY) {
Write-Host "::error::setup-venv built '$env:SETUP_VENV_PY' but this job runs '$env:VENV_PY'. Point VENV_PY/VENV_GAIA in the job env at setup-venv's venv-name."
exit 1
}
foreach ($exe in @($env:VENV_PY, $env:VENV_GAIA)) {
if (-not (Test-Path $exe)) {
Write-Host "::error::$exe is missing - the venv install did not complete. See the Setup Python Environment step."
exit 1
}
}
& $env:VENV_PY -c "import gaia, gaia_agent, gaia_agent_chat"
if ($LASTEXITCODE -ne 0) {
Write-Host "::error::$env:VENV_PY cannot import gaia / gaia_agent / gaia_agent_chat - the editable installs in the Setup Python Environment step are broken."
exit 1
}
$bare = (Get-Command python -ErrorAction SilentlyContinue).Source
Write-Host "Venv interpreter: $env:VENV_PY"
Write-Host "Bare 'python' on this runner: $(if ($bare) { $bare } else { 'none' })"
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Install Claude Code CLI (the judge)
run: |
npm install -g @anthropic-ai/claude-code
if ($LASTEXITCODE -ne 0) { throw "Claude Code CLI install failed" }
claude --version
if ($LASTEXITCODE -ne 0) { throw "claude installed but not runnable on PATH" }
- name: Start GAIA's embedded Lemonade
timeout-minutes: 20
run: |
$ErrorActionPreference = "Continue"
& $env:VENV_GAIA lemonade embedded start --timeout 180
if ($LASTEXITCODE -ne 0) {
Get-Content "$HOME\.gaia\lemonade\lemond.log" -Tail 60 -ErrorAction SilentlyContinue
throw "The embedded Lemonade did not start; its log is above."
}
& $env:VENV_GAIA lemonade embedded install-backend llamacpp:vulkan
if ($LASTEXITCODE -ne 0) { throw "Could not install llamacpp:vulkan into the embedded Lemonade." }
& $env:VENV_GAIA lemonade embedded status
# This pool is ephemeral, so the model is never cached. Download it here,
# or the first task's clock pays for a 23 GB download.
- name: Download the task model
timeout-minutes: 60
run: |
. "$HOME\.gaia\lemonade\env.ps1"
& $env:VENV_PY -c "import os, sys; from gaia.llm.lemonade_client import LemonadeClient; sys.exit(0 if LemonadeClient().ensure_model_downloaded(os.environ['TASK_MODEL'], timeout=3300) else 1)"
if ($LASTEXITCODE -ne 0) { throw "Could not download $env:TASK_MODEL into the embedded Lemonade; the error is above." }
# No credentials in this step's environment: the agent runs shell commands.
- name: Run the flagship on the task suites
timeout-minutes: ${{ github.event_name != 'pull_request' && 240 || 60 }}
run: |
$ErrorActionPreference = "Continue"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
. "$HOME\.gaia\lemonade\env.ps1"
# One suite crashing must not stop the next from being measured.
$crashed = @()
foreach ($suite in ($env:TASK_SUITES -split ' ')) {
& $env:VENV_GAIA eval tasks run --suite $suite --model $env:TASK_MODEL --no-judge --out "$env:TASKS_OUT\$suite"
if ($LASTEXITCODE -ne 0) { $crashed += "$suite (exit $LASTEXITCODE)" }
}
if ($crashed.Count -gt 0) { throw "gaia eval tasks run crashed for: $($crashed -join ', '). A harness failure, not a verdict." }
- name: Judge quality
if: ${{ !cancelled() }}
timeout-minutes: ${{ github.event_name != 'pull_request' && 60 || 30 }}
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ (vars.CLAUDE_ACCOUNT == 'secondary' && secrets.CLAUDE_CODE_OAUTH_TOKEN_SECONDARY || secrets.CLAUDE_CODE_OAUTH_TOKEN) }}
# Blank when the OAuth token is set: the judge adds --bare only with a key.
ANTHROPIC_API_KEY: ${{ (vars.CLAUDE_ACCOUNT == 'secondary' && secrets.CLAUDE_CODE_OAUTH_TOKEN_SECONDARY || secrets.CLAUDE_CODE_OAUTH_TOKEN) == '' && secrets.ANTHROPIC_API_KEY || '' }}
run: |
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$failed = @()
foreach ($suite in ($env:TASK_SUITES -split ' ')) {
$out = "$env:TASKS_OUT\$suite"
if (-not (Test-Path "$out\scorecard.json")) { $failed += "$suite (no results to judge)"; continue }
& $env:VENV_GAIA eval tasks judge $out --judge-model $env:TASK_JUDGE_MODEL --judge-attempts 2
if ($LASTEXITCODE -ne 0) { $failed += "$suite (exit $LASTEXITCODE)" }
}
if ($failed.Count -gt 0) { throw "gaia eval tasks judge failed for: $($failed -join ', ')." }
# Always enforced. With no committed expectations for the model yet it only
# reports; the proposal in the artifact is the file to commit from main.
- name: Gate on the committed expectations
id: gate
if: ${{ !cancelled() }}
run: |
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$failed = @()
foreach ($suite in ($env:TASK_SUITES -split ' ')) {
$out = "$env:TASKS_OUT\$suite"
if (-not (Test-Path "$out\scorecard.json")) { $failed += "$suite (no results - its run crashed)"; continue }
& $env:VENV_GAIA eval tasks gate $out --enforce --propose "$out\proposed-expectations.json"
if ($LASTEXITCODE -ne 0) { $failed += "$suite (exit $LASTEXITCODE)" }
}
"failed=$($failed -join ', ')" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
if ($failed.Count -gt 0) {
Write-Host "::error::The outcomes gate failed for: $($failed -join ', '). Each suite's verdict is printed above."
exit 1
}
- name: Stop the embedded Lemonade
if: always()
run: '& $env:VENV_GAIA lemonade embedded stop'
- name: Keep the embedded Lemonade log
if: always()
run: |
$log = "$HOME\.gaia\lemonade\lemond.log"
if (Test-Path $log) { Copy-Item $log "$env:TASKS_OUT\lemond.log" -Force }
# Scans EVERY file that uploads, not a hand-kept subset, so the two lists
# cannot drift apart. The artifact is public and, unlike the job log, not
# secret-masked.
- name: Refuse to upload a credential
if: always()
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ (vars.CLAUDE_ACCOUNT == 'secondary' && secrets.CLAUDE_CODE_OAUTH_TOKEN_SECONDARY || secrets.CLAUDE_CODE_OAUTH_TOKEN) }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
$ErrorActionPreference = "Stop"
$files = @(Get-ChildItem $env:TASKS_OUT -File -Recurse -ErrorAction SilentlyContinue)
$secrets = @()
foreach ($name in @("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY")) {
$value = [Environment]::GetEnvironmentVariable($name)
if ($value) { $secrets += $value }
}
$leaked = @()
foreach ($file in $files) {
if (-not (Test-Path -LiteralPath $file.FullName)) { continue }
$hit = $false
foreach ($value in $secrets) {
if (Select-String -LiteralPath $file.FullName -SimpleMatch -Pattern $value -Quiet) { $hit = $true }
}
if (-not $hit) { continue }
$leaked += $file.FullName
$text = [System.IO.File]::ReadAllText($file.FullName)
foreach ($value in $secrets) { $text = $text.Replace($value, "***REDACTED***") }
[System.IO.File]::WriteAllText($file.FullName, $text, (New-Object System.Text.UTF8Encoding($false)))
}
if ($leaked.Count -gt 0) {
Write-Host "::error::A credential appeared in $($leaked.Count) file(s) bound for the PUBLIC artifact: $($leaked -join ', '). They were redacted in place before upload, but a secret reaching a result file at all means something is printing it - find that and fix it, then rotate the credential."
exit 1
}
if ($files.Count -eq 0) {
Write-Host "::warning::No files under $env:TASKS_OUT - nothing was scanned, and nothing will upload."
} else {
Write-Host "Scanned $($files.Count) file(s): no credential found."
}
# This machine is destroyed when the job ends, so the per-task transcripts
# and the Lemonade log go with it unless they leave here.
- name: Upload the outcomes results
if: always()
uses: actions/upload-artifact@v7
with:
name: eval-outcomes
path: eval-out/tasks
if-no-files-found: warn
retention-days: 14
agent-eval:
name: Scenario eval - ${{ matrix.lane }}
needs: [eval-gate, lanes]
strategy:
# One lane's failure must not cancel the others. They measure different
# categories on different machines, and a lane that dies is exactly when
# the other lanes' scorecards are most worth having.
fail-fast: false
matrix: ${{ fromJSON(needs.lanes.outputs.matrix) }}
# Per LANE, not per workflow. CLAUDE.md's "run evals serially" rule is about
# one Lemonade backend race-evicting its own model slot; lanes get separate
# machines and therefore separate backends, so they are free to run at once.
# Within a lane, categories still run one at a time.
#
# Must stay keyed on matrix.lane. A group shared across lanes would make the
# second lane wait for the first and undo the split; a group shared with a
# CALLER would deadlock outright (the phantom-job bug).
concurrency:
group: ${{ github.workflow }}-${{ matrix.lane }}-${{ github.ref }}
cancel-in-progress: false
# Fork PRs and drafts never reach the lab pool. A fork PR would schedule
# untrusted code onto a self-hosted runner and could not pass anyway
# (ANTHROPIC_API_KEY is not exposed to fork PRs, so the judge preflight
# fails); a draft would spend a multi-hour single-slot run on work still in
# progress. A job-level `if` resolves before a runner is assigned, so a
# skipped job never reaches the pool at all. `ready_for_ci` is the repo-wide
# escape hatch for a draft that does want CI. Mirrors the guard in
# test_email_agent_eval.yml.
#
# Kept flat-indented on purpose: a YAML folded scalar preserves newlines on
# more-indented lines, and an `if:` expression must fold to a single line.
if: >-
!cancelled() && needs.eval-gate.result == 'success' &&
needs.eval-gate.outputs.run == 'true' && needs.lanes.result == 'success' &&
needs.lanes.outputs.has_lanes == 'true' &&
(github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
(github.event.pull_request.draft == false ||
contains(github.event.pull_request.labels.*.name, 'ready_for_ci'))))
# `Windows` is load-bearing, not decoration: self-hosted pool labels here also
# match a Linux box (xsj-aimlab-stxp-04), and every step below is PowerShell +
# LemonadeServer.exe. Landing on Linux dies with 'powershell: command not
# found'. Same guard as test_email_agent_eval.yml.
#
# `strix-halo` pins the SoC, and that is a correctness requirement, not a
# preference: the pool spans Strix Point boxes as well (sjlab-stx-1,
# sjlab-stx-3), which do not have the 128 GB of unified memory this eval's
# model needs, and scores from two different SoCs are not comparable to each
# other or to the committed baselines. Do NOT widen this to `stx` — an
# earlier revision of this comment suggested exactly that, which would have
# let a scorecard be produced on whichever machine happened to be free.
#
# `devlab-dispatch` is the ephemeral pool: every lane gets its own freshly
# provisioned machine, which is what lets the lanes run at once — separate
# machines means separate Lemonade backends, and the serial rule is about one
# backend's model slot. The machine is DESTROYED when the job ends, which is
# why the upload step at the bottom takes traces and logs and not just scores.
#
# `lemonade-eval` is deliberately NOT here. It is the lock label for the one
# persistent Halo box, and holding it would put every lane back in a single
# queue. The email and publish workflows still use it; this one must not.
runs-on: [self-hosted, Windows, devlab-dispatch, strix-halo]
defaults:
run:
# Pinned to Windows PowerShell 5.1, not left to the runner default (pwsh
# when installed, powershell otherwise). Everything below is written to
# 5.1's rules — the Tee-Object/`2>&1` and $LASTEXITCODE notes are
# 5.1-specific — and this file's first run died because nothing declared
# a shell at all and the steps were bash.
#
# KEEP EVERY `run:` BODY PURE ASCII. Actions writes the block to a .ps1
# with no BOM, and PowerShell 5.1 reads a BOM-less file as ANSI, not
# UTF-8 — so one em dash inside a `run:` string is three cp1252 bytes
# mid-token and the whole step dies at parse time before a line executes.
# (Run 32274575191: a single `—` in an ::error:: message produced
# "Missing argument in parameter list" 30 lines away from itself.) YAML
# comments and step `name:` values are fine; they never reach the file.
shell: powershell
# BUDGET DERIVATION — measured on this pool, not guessed. Sized from the
# [TIMING] lines of 19 complete runs (2026-09-17 .. 09-21) plus the
# per-scenario [DONE] timings inside 8 of them, which is what makes this a
# per-SCENARIO rate rather than a per-category one:
#
# 140 healthy scenarios sampled 4.05 min mean, 6.73 max, 2.00 min
# per-category overhead none measurable — a category's [TIMING]
# minus the sum of its scenarios: 0.04 min
# mean, 0.10 max across 33 category-runs
#
# A category therefore costs exactly what its scenarios cost, so the lane
# budget is one multiplication. WHY SCENARIO COUNT IS THE BALANCING UNIT:
# category means span only 3.86-4.52 min (tool_selection / rag_quality /
# context_retention, the three CI has ever run), while scenarios INSIDE one
# category span 2.00-6.73. Scenario identity explains about twice the
# variance that category does, so minutes-per-category would move these lane
# boundaries by minutes, not tens of minutes.
#
# tools 18 -> 72 min retrieval 29 -> 116 memory 25 -> 101
# conversation 10 -> 42 outcomes: its own job
# gaia-* lanes 23-34 scenarios each (CI-runnable, after tag exclusions)
#
# Lanes grow as scenarios are added, so the step limit is generous rather
# than a cap to balance against: 360 min covers ~53 scenarios at the worst
# observed 6.73 min. It exists only so an overrun fails here with partial
# results still collected; rebalance when a lane's [TIMING] nears it.
#
# The ceiling is sized for the LARGEST lane rather than per lane; a smaller
# lane finishing early costs nothing on a machine that is discarded anyway:
#
# eval step (see above) -> 360
# setup on a COLD machine (venv, npm, Lemonade install + model) -> 45
# collect + scrub + integrity + compare + upload -> 15
# job: 360+45+15 -> 420
#
# Serial, all 82 scenarios in one job, this was 347 min expected / 552 ceiling.
# The split trades that for four ~2 h lanes that finish together.
#
# Only 13 of the 82 are priced on a MEASURED category mean; the rest use the
# global one, because CI has never run their category. Watch `memory` as real
# numbers arrive: 25 scenarios at the highest turn count in the repo, and
# turns are the one structural driver that correlates with duration at all.
# Rebalance by editing eval/ci_lanes.json; the [TIMING] lines and each lane's
# lane-timing.json exist to supply the real number.
#
# The eval step carries its OWN timeout-minutes below so an overrun fails at a
# NAMED step instead of a silent job-level `cancelled` — a job-level timeout
# skips the `!cancelled()` collect/integrity/compare steps AND the upload,
# which on an ephemeral machine destroys the partial results permanently.
# That is how run 30869194618 threw away 124 min of rag_quality data.
timeout-minutes: 420
env:
# The venv's interpreter and CLI by full path. A bare `python` resolves
# through PATH, which a runner's own setup can put ahead of the venv.
VENV_PY: ${{ github.workspace }}\.venv\Scripts\python.exe
VENV_GAIA: ${{ github.workspace }}\.venv\Scripts\gaia.exe
# The lane's categories, from eval/ci_lanes.json via the `lanes` job. Every
# step below reads this one list, so run / collect / gate / compare cannot
# disagree about what this lane was supposed to measure.
EVAL_CATEGORIES: ${{ matrix.categories }}
EVAL_LANE: ${{ matrix.lane }}
# Targeted dispatch only: "category=id,id category=id". Blank runs every
# scenario in each category, which is what a tag run always does.
EVAL_SCENARIOS: ${{ matrix.scenarios }}
EVAL_SELECTION: ${{ needs.lanes.outputs.selection }}
EVAL_TARGETED: ${{ needs.lanes.outputs.targeted }}
EVAL_TIER: ${{ needs.lanes.outputs.tier }}
# The local model under test, pinned in every category's GAIA home and
# checked per scenario, and the baseline captured on that model. Both
# from eval/ci_tiers.json via the lanes job, so they cannot disagree.
AGENT_MODEL: ${{ needs.lanes.outputs.agent_model }}
BASELINE_DIR: ${{ needs.lanes.outputs.baseline_dir }}
# Every run, tag runs included, fails on scenarios that errored before
# Claude spent a token (an exhausted quota, as on v0.25.1-rc2). That is a
# run that measured nothing, not a regression, so report mode does not
# soften it.
FAIL_ON_ZERO_TOKENS: 'true'
# Memory ON for the memory lane, OFF everywhere else. Job scope beats the
# workflow defaults, and everything that reads these is started from here:
# the backend inherits the job environment through Start-Process, and the
# MCP server inherits it from the backend. The values are decided in the
# `lanes` job and arrive as literal strings - see the comment there for why
# they are not computed in YAML.
GAIA_MEMORY_DISABLED: ${{ matrix.memory_disabled }}
GAIA_MEMORY_ADMIN: ${{ matrix.memory_admin }}
GAIA_MEMORY_MCP_ALWAYS: ${{ matrix.memory_mcp }}
GAIA_FIXTURES: ${{ matrix.gaia_fixtures }}
# A tier machine can hold `memory` beside other non-gaia categories. The
# job runs with memory off, and the run step turns the store on for the
# categories named here only, each on its own backend and GAIA home.
EVAL_MEMORY_CATEGORIES: ${{ matrix.memory_categories }}
steps:
- name: Show which Claude account this job uses
run: echo "::notice title=Claude account::${{ vars.CLAUDE_ACCOUNT != 'secondary' && 'This job uses the primary Claude account.' || secrets.CLAUDE_CODE_OAUTH_TOKEN_SECONDARY != '' && 'This job uses the secondary Claude account.' || 'CLAUDE_ACCOUNT is secondary but CLAUDE_CODE_OAUTH_TOKEN_SECONDARY is not set, so this job uses the primary Claude account.' }}"
- name: Checkout
uses: actions/checkout@v7
# The Claude model drives every scenario as well as judging it, so it must
# be the one the model's baseline was captured under: its meta.json is the
# single source. A dispatch input overrides it.
- name: Resolve the eval driver model
run: |
$model = $env:EVAL_MODEL_OVERRIDE
if (-not $model) {
$meta = "$env:BASELINE_DIR/meta.json"
$model = (Get-Content -Raw $meta | ConvertFrom-Json).eval_model
if (-not $model) {
Write-Host "::error title=No eval model::$meta has no eval_model, so this run cannot know which Claude model the baseline was captured under. | WHAT TO DO: restore eval_model in $meta, or dispatch with the eval_model input."
exit 1
}
}
"EVAL_MODEL=$model" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii
Write-Host "Eval driver and judge model: $model"
# A memory lane with memory switched off does not fail - it scores badly,
# and the scorecard reads as a quality regression rather than a setup
# fault. So re-derive what this lane needs from its own category list and
# refuse to start if the environment disagrees, before anything has run.
# Independent of the matrix wiring on purpose: the point is to catch that
# wiring going wrong, so it cannot be checked against itself.
- name: Check the lane environment matches its categories
run: |
$ErrorActionPreference = "Stop"
$cats = @($env:EVAL_CATEGORIES -split '\s+' | Where-Object { $_ })
$needsMemory = $cats -contains 'memory'
$gaiaLane = @($cats | Where-Object { $_ -like 'gaia_*' }).Count -gt 0
# A tier machine with memory beside other non-gaia categories runs
# with memory off and turns it on for `memory` alone (run step).
$mixedMemory = $needsMemory -and $cats.Count -gt 1 -and -not $gaiaLane
if ($gaiaLane) {
$want = [ordered]@{ GAIA_MEMORY_DISABLED = '0'; GAIA_MEMORY_ADMIN = '1'; GAIA_MEMORY_MCP_ALWAYS = '0'; GAIA_FIXTURES = '1'; EVAL_MEMORY_CATEGORIES = '' }
$why = "gaia_* categories must run the way test_gaia_agent_eval.yml runs them and their baselines were captured: memory live with admin seeding, fixtures staged."
} elseif ($mixedMemory) {
$want = [ordered]@{ GAIA_MEMORY_DISABLED = '1'; GAIA_MEMORY_ADMIN = '0'; GAIA_MEMORY_MCP_ALWAYS = '0'; GAIA_FIXTURES = '0'; EVAL_MEMORY_CATEGORIES = 'memory' }
$why = "This machine runs memory beside other categories: the job runs with the store off and the run step turns it on for the memory category alone, so EVAL_MEMORY_CATEGORIES must name exactly memory."
} elseif ($needsMemory) {
$want = [ordered]@{ GAIA_MEMORY_DISABLED = '0'; GAIA_MEMORY_ADMIN = '1'; GAIA_MEMORY_MCP_ALWAYS = '1'; GAIA_FIXTURES = '0'; EVAL_MEMORY_CATEGORIES = '' }
$why = "The memory scenarios call remember/recall and verify through memory_recall, so this lane must run with the store live and its admin and MCP read tools registered."
} else {
$want = [ordered]@{ GAIA_MEMORY_DISABLED = '1'; GAIA_MEMORY_ADMIN = '0'; GAIA_MEMORY_MCP_ALWAYS = '0'; GAIA_FIXTURES = '0'; EVAL_MEMORY_CATEGORIES = '' }
$why = "No category in this lane touches long-term memory, and leaving the store live lets state from one scenario change the next one's score."
}
$wrong = @()
foreach ($name in $want.Keys) {
$have = [Environment]::GetEnvironmentVariable($name)
# Windows drops an env var set to '', so unset IS the empty value.
if ($null -eq $have) { $have = if ($want[$name] -eq '') { '' } else { '<unset>' } }
if ($have -ne $want[$name]) { $wrong += "$name is '$have' but should be '$($want[$name])'" }
}
if ($wrong.Count -gt 0) {
Write-Host "::error::Lane '$env:EVAL_LANE' runs [$env:EVAL_CATEGORIES] but its environment is wrong: $($wrong -join '; '). $why The per-lane values come from util/eval_lane_matrix.py via the lanes job - fix them there, not in the workflow-level defaults, which would change every lane."
exit 1
}
Write-Host "Lane '$env:EVAL_LANE' environment is correct (memory lane: $needsMemory, gaia_* lane: $gaiaLane)."
- name: Setup Python Environment
id: venv
uses: ./.github/actions/setup-venv
with:
python-version: '3.12'
# [dev] + [eval] for the eval harness and the Anthropic judge deps;
# [ui] for fastapi/uvicorn + the RAG deps gaia.ui.server imports at boot
# ([dev] alone fails at import), [api] for the connectors layer.
# Both hub packages are REQUIRED, and neither is pulled by those extras.
# Every scenario runs the flagship `gaia` agent (DEFAULT_AGENT_TYPE in
# src/gaia/eval/config.py), which is not a builtin — it registers only
# through the `gaia.agent` entry point in gaia-agent-gaia, and GaiaAgent
# subclasses ChatAgent from gaia-agent-chat (#1102). Miss either and
# POST /api/sessions 422s for every scenario in this lane. The "default
# agent is registered" preflight below asserts this at runtime so the
# gap costs seconds, not a whole lane. Core gaia stays editable so eval
# fixtures resolve in-repo.
# `mcp` is not optional for this gate: every scenario is driven through
# the Agent UI MCP server, and without the extra that server exits with
# ModuleNotFoundError before writing a protocol frame — which the client
# reports only as CONNECTION_CLOSED.
install-package: '-e .[dev,eval,ui,api,mcp] -e hub/agents/chat/python -e hub/agents/gaia/python'
- name: Check the venv interpreter
env:
SETUP_VENV_PY: ${{ steps.venv.outputs.python-path }}
run: |
if ($env:SETUP_VENV_PY -ne $env:VENV_PY) {
Write-Host "::error::setup-venv built '$env:SETUP_VENV_PY' but this job runs '$env:VENV_PY'. Point VENV_PY/VENV_GAIA in the job env at setup-venv's venv-name."
exit 1
}
foreach ($exe in @($env:VENV_PY, $env:VENV_GAIA)) {
if (-not (Test-Path $exe)) {
Write-Host "::error::$exe is missing - the venv install did not complete. See the Setup Python Environment step."
exit 1
}
}
& $env:VENV_PY -c "import gaia, gaia_agent, gaia_agent_chat"
if ($LASTEXITCODE -ne 0) {
Write-Host "::error::$env:VENV_PY cannot import gaia / gaia_agent / gaia_agent_chat - the editable installs in the Setup Python Environment step are broken."
exit 1
}
$bare = (Get-Command python -ErrorAction SilentlyContinue).Source
Write-Host "Venv interpreter: $env:VENV_PY"
Write-Host "Bare 'python' on this runner: $(if ($bare) { $bare } else { 'none' })"
# `gaia eval agent` does not merely call the Anthropic API - it shells out
# to `claude -p` with an MCP config to DRIVE each scenario (runner.py:937),
# and its own preflight hard-fails with "'claude' CLI not found on PATH"
# before running anything. ANTHROPIC_API_KEY is necessary but not
# sufficient; the CLI has to be installed too. Same prerequisite the
# eval-CI recipe documents (docs/guides/eval-ci.mdx).
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Install Claude Code CLI (the eval's scenario driver)
run: |
npm install -g @anthropic-ai/claude-code
if ($LASTEXITCODE -ne 0) { throw "Claude Code CLI install failed" }
# Fail here, not after the backend has booted.
claude --version
if ($LASTEXITCODE -ne 0) { throw "claude installed but not runnable on PATH" }
- name: Install Lemonade Server