Anyone can prompt an LLM into building a travel agent demo. The differentiator is proving it
works β and keeps working β with a repeatable, numeric harness. This page is the running receipt: every
time evals/run.py surfaces something real, it gets an entry below, with the before/after
evidence. It's updated every time we discover something new, not rewritten after the fact.
Every entry below follows the same loop: the eval suite measures something, a specific number moves or a specific run fails, we root-cause it, we fix it at the right layer (never by begging the model harder in the prompt), and we re-run to confirm. That loop β not the travel-planning demo itself β is the part that's hard to fake and expensive to skip.
60 pytest tests covered code and stayed green the whole time the model was silently reporting wrong subtotals. Only a behavioral eval β the real LLM, a real prompt, adversarial fixtures β caught it.
The data layer (flights, hotels, research) is faked deterministically, so when a number moves, it can only be the model or the prompt β never "Kiwi changed its prices today."
The headline finding below is deliberately not "we told Gemini to be more careful with math." Arithmetic moved out of the model's job entirely β evals are what make that kind of fix findable.
Chronological, oldest first β each entry is appended when a run teaches us something, never edited after the fact to look tidier. Full mechanics of the suite (fixtures, evaluators, harness) live in docs/evals.md.
ask_clarifying_question is a frontend tool (CopilotKit's human-in-the-loop card) β
it has no implementation outside the browser. The first headless runs silently burned their output-retry
budget every time the model reached for it. Learning: evaluability isn't free; it required a
scripted stub toolset in the harness from day one, and extracting ResearchServicePort so
grounded web search could be swapped for a fake like every other data source. All 59 pytest tests stayed
green through the refactor.
The very first live run against production gemini-2.5-flash flagged one assertion,
itemized_costs_sum_to_subtotals, at 75%. Everything else β budget caps, direct-only
max_stops=0, no-red-eye legs, party-size propagation, empty-route fallback, per-island
lodging β passed cold on the first try.
Diagnosis of the failing island-hop run showed the model's itemized legs and hotels were correct and complete β but the subtotals it reported were fiction: transportation β¬600 when its own six legs summed to β¬1252. This is a data-gathering success and a pure arithmetic failure.
Before β itemized_costs_sum_to_subtotals: 75% (9/12)
Fix: stopped delegating arithmetic to the model. generate_scenarios
now re-derives transportation/accommodation subtotals by summing the model's own
Leg.cost / Accommodation.cost line items server-side, then the grand total from
those subtotals β deterministic code, not a prompt plea. A new unit test
(test_generate_scenarios_normalizes_subtotals_from_line_items) locks it in.
After re-run β itemized_costs_sum_to_subtotals: 100%, every category 100%.
Re-running the full suite after the cost fix landed a new, different failure: the heaviest case in the
set (a 12-day Greek island hop rendered as one fully-detailed plan) passed in the baseline but
errored on the very next run with UnexpectedModelBehavior: Exceeded maximum output retries.
Flagged rather than chased immediately β a single data point isn't a diagnosis, it's a reason to run a
variance study.
assert can't
Added human-in-the-loop cases (does the agent ask when info is genuinely missing, and stay quiet when
it isn't?), multi-turn refinement cases (shared message_history across turns β "now add a
cheaper option", "make it for 4 people instead"), and a small LLM-as-judge (evals/judge.py)
for soft criteria no deterministic check can express: is the chat reply a concise recommendation rather
than a wall of prose, and do the day-by-day activities actually match the requested vibe ("relaxed
foodie", "adventure hiking")? The judge is deliberately decoupled from the agent under test β see below.
The first full variance pass (30 cases Γ 3 repeats at concurrency 6) came back with almost everything
"erroring." Reading the actual error bodies showed 429 RESOURCE_EXHAUSTED quota errors and
504 DEADLINE_EXCEEDED timeouts β the harness was firing more concurrent Gemini requests than
the API tier allows, not a model or prompt regression. Practice adopted: re-run flagged cases at
low concurrency before treating any failure as a real signal; the runner's per-case aggregation now
separates error_runs from genuine assertion failures for exactly this reason.
At safe concurrency, 22 of 30 cases ran clean with 100% of executed assertions passing β
budgets, currency propagation, direct-only, no-red-eye, party-size (solo through groups of 6), ferry/rail
multi-city chains, HITL ask/don't-ask, multi-turn refinement, and the LLM-judge criteria all held.
Re-running the 8 flagged cases individually separated 6 transient infra errors from 2 reproducible
failures: greek_island_hop_single_plan and vibe_relaxed_foodie β both single,
maximally-detailed 12-day plans β consistently hit
UnexpectedModelBehavior: Exceeded maximum output retries. Root cause: on a very large
final-synthesis context, Flash intermittently returns an empty completion with no first token.
Raised the agent's output-retry budget from the pydantic-ai default of 1 to 2
(retries={'tools': 2, 'output': 2}) to give a large synthesis a second chance at a
non-empty completion. Re-verified on the same two cases: still fails, now as
Exceeded maximum output retries (2). This is the honest outcome of the loop, not a hidden
one β the fix has to be architectural (lighten the day-by-day detail the prompt asks for in single-plan
mode, or split the synthesis into two calls), and that's queued as the next thing the eval suite will
verify once it ships.
The LLM judge now defaults to gemini-2.5-flash-lite β cheap and plenty for a boolean
rubric β while the agent under test stays on whatever ships in LLM_MODEL, so a judge-cost
optimization can never quietly change what's actually being measured. Both are configurable three ways
(CLI flag β env var β default) via EVAL_MODEL / EVAL_JUDGE_MODEL in
backend/.env, and every JSON report now records exactly which model played which role for
full reproducibility.
1) False-negative in the harness itself: infeasible_budget_no_fabrication failed
produced_a_plan for correctly refusing to invent a plan for an impossible budget β
the evaluator, not the agent, was wrong. Fixed by skipping that assertion when
CaseMeta.budget_infeasible is set. 2) Ambiguous case wording:
multi_city_rail_europe said PragueβVienna must be "BY TRAIN" but left the Berlin legs
unspecified β the model reasonably flew them, failing ground_legs_present. Reworded to
"no flights at all, including the way back" and added explicit forbidden_tools.
3) The real one: group_of_six_propagation β phrasing the travelers as "a group of
friends" instead of "6 people" made the agent skip search_flights/search_accommodations
entirely and hand-wave numbers straight into generate_scenarios. Casual framing was being read
as license to skip the tools. Fixed with a new system-prompt directive: informal phrasing describes the
travelers, never a license to fabricate prices.
All three cases: 3/3 repeats clean after their fix; the full 30-case suite re-run confirmed no regressions.
already_have_flights_zero_transport
reproducibly hits 504 DEADLINE_EXCEEDED even run in isolation with extended timeouts β a
Google-side infra issue pydantic-ai's retry logic doesn't currently absorb. The output-retry-exhaustion
issue above has now also been seen on refine_change_party_size and
empty_route_falls_back_and_flags_estimated β logged as open rather than quietly ignored.Every case run now flows through the same Langfuse/OpenTelemetry pipeline as production
(app/telemetry.py), tagged environment="eval", session_id=<run
timestamp> (groups every case + repeat of one evals.run invocation into one Langfuse
session), trace_name=<case name>, and tags=["kompass-eval", <category>].
Production traces got the mirror-image environment="production" so the two populations never
mix in a shared Langfuse project.
Verified live, not just wired: ran
evals.run --case group_of_six_propagation and queried the Langfuse API directly β the trace
landed with environment=eval, traceName=group_of_six_propagation,
sessionId matching the run's timestamp, and eval_category/eval_repeat
metadata on every span, alongside the full token/cost/tool-call detail pydantic-ai already emits.
_debug_case.py script and raw stdout. Next time, it's a Langfuse query β filter
environment = eval, traceName = <case> β and see the model's exact
reasoning and tool-call arguments for every attempt, with zero one-off scripts and zero noise in
production dashboards. See evals.md Β§10
for the mechanics.A full 30-case run flagged empty_route_falls_back_and_flags_estimated failing
prices_flagged_estimated. Instead of writing a throwaway debug script, filtered Langfuse to
environment = eval, traceName = empty_route_falls_back_and_flags_estimated, and
read the generate_scenarios tool call directly: the model's own reasoning_summary
said "the total cost is an estimate due to flight prices being sourced from web search" β but the
structured estimated argument it actually passed to the tool was false. Right
reasoning, wrong structured output β the same failure shape as the itemized-costs bug (Β§7 in
evals.md), just far less frequent.
Quantified before acting: repeated the case 9 more times rather than
prompt-patching off one sample. Result: 8/9 passed prices_flagged_estimated cleanly β
genuine low-rate model flakiness, not a deterministic bug, so left unfixed rather than overfitting a prompt
change to one data point.
Exceeded maximum output retries (2) error β but on this small, single-leg
case, not a heavy multi-day synthesis. The trace showed the empty completion firing right after a plain
search_web response, before generate_scenarios was ever called. That downgrades
"large final-synthesis context" from root cause to correlation β the real trigger looks like an
intermittent Flash empty-completion glitch on any post-tool-call turn. Still open, still needs an
architectural fix, but now a more accurate diagnosis of what to fix.Roadmap item 3.5, done. The trace tagging shipped earlier answers "let me look at this run";
this closes the loop with the other question β "how did case X, or category Y, score across
every run and prompt/model version?" Each evals.run now mirrors the golden set into a
Langfuse dataset (kompass-golden) and publishes the run as a dataset run: one item-run per
case linked to its agent trace, one BOOLEAN score per assertion, plus per-category and overall
NUMERIC scores on the run itself β so Langfuse's dataset comparison view charts and diffs score
history across versions.
Design β mirror, don't re-run: kept pydantic-evals as the source of truth
rather than re-scoring on Langfuse's own experiment runner (which would double the model spend). The catch was
linking each Langfuse score to the right case: pydantic-evals owns the execution loop, so
evals/tasks.py wraps each case in a Langfuse span and stamps its trace id onto the
ReportCase via set_eval_attribute("langfuse_trace_id", β¦) β an exact, per-row link.
evals/langfuse_dataset.py then does the sync + publish; failed cases (which lose their attributes)
fall back to a per-run trace registry. On by default when Langfuse keys are set, skippable with
--no-langfuse-dataset, a no-op otherwise.
evals.run and queried
the Langfuse API back β the dataset run was created, its item linked to the agent trace, and all four assertion
scores plus overall_pass_rate and category:human_in_the_loop run-level scores were
confirmed present. 67 backend tests green. See
evals.md Β§10.1 for the mechanics.Ran the whole suite (uv run python -m evals.run, gemini-2.5-flash agent /
flash-lite judge): 29/30 cases clean with 100% of executed assertions passing across all
seven categories. The one failure was the known-open greek_island_hop_single_plan
output-retry exhaustion (Β§9), plus a transient 503 from the judge model on one case (infra
noise, not a regression). The Langfuse dataset run published correctly and was verified back via the API:
30/30 run items linked to their agent traces β including the failed case, linked via the trace registry
fallback and flagged task_error=1 β with per-assertion scores and the run-level
overall_pass_rate/category:* aggregates all present.
environment=eval, but a handful of stray agent run traces were
landing in the default environment. Root cause: the LLM-as-judge runs inside evaluators,
after the task and outside the per-case trace context, so its calls weren't inheriting the eval tags.
Fixed by wrapping the judge's own run in trace_attributes(environment="eval", trace_name="judge",
tags=["kompass-eval","judge"]) (and naming the agent kompass_judge) β verified the judge
call now lands as a judge trace in environment=eval. Pre-existing, not caused by the
dataset-run work, but caught because the full run made it visible. 67 backend tests green.Follow-up to the dataset-run work above: the runs existed and every score was present via the API, yet the
dataset's Experiments tab showed "No data". Root cause: linking a trace to a run with the low-level
api.dataset_run_items.create call produces a valid dataset run, but the Experiments tab only
surfaces traces that the SDK's own run_experiment() marks as experiment items β
environment="sdk-experiment" plus a set of langfuse.experiment.* span attributes. Our
traces were tagged environment=eval with none of those attributes, so they never qualified.
run_experiment() does per item, minus
re-running the agent (keeping pydantic-evals as the executor). During the run, tasks.py now calls
create_run_item() (links the case's own agent trace to the run) and experiment_context()
(flips the root span to the sdk-experiment environment and propagates the experiment identity β
id/name/dataset/item β to the agent's child spans via the SDK's own propagation helper). Scores are written in
that same sdk-experiment environment so the tab's score columns line up. publish_report()
is now scores-only (the run + items are created during the run). Verified via the API: each case's trace lands in
sdk-experiment with a linked run item, and task_error/assertion/run-level scores are
present in that environment. Also hardened a latent crash β report.case_groups is a method, not a
list, so the empty-report.cases fallback (every case errored) used to raise
TypeError. 67 backend tests green.Swapped the agent-under-test default from gemini-2.5-flash to
gemini-3.1-flash-lite (app/config.py; the judge stayed decoupled on
gemini-2.5-flash-lite on purpose) and validated the switch with a same-day A/B rather than
trusting the model card. A --concurrency 4 run on the old model came back noisy β several
categories (human_in_the_loop, itinerary_quality) produced zero assertions after
429 RESOURCE_EXHAUSTED quota errors ate most of the run, landing 62/63 (98.4%) of what did
execute. Re-running at --concurrency 2 on the new model completed cleanly across all seven
categories β 152/153 (99.3%) β at roughly 34% lower per-case token cost.
infeasible_budget_no_fabrication now fails no_overbudget_plan (0/1). Given a
deliberately impossible budget, the old model correctly declined it; the new, cheaper model instead
produces a plan that blows the budget β a fabrication-adjacent failure this case exists specifically to
catch. The model swap shipped anyway (a clear win on 6 of 7 categories plus real cost savings), but this
regression is logged open, not quietly absorbed into a headline pass-rate that still looks good.gemini-2.5-flash-lite (the default EVAL_JUDGE_MODEL) began returning
503 UNAVAILABLE ("high demand") on judge-backed assertions
(concise_recommendation, vibe_matches_activities) β a transient Google-side
capacity ceiling, not a prompt or model problem, but enough to silently drop a check or fail a whole run.
Fix: judge.py now wraps the judge agent in PydanticAI's
FallbackModel, retrying the next model in the new EVAL_JUDGE_FALLBACK_MODELS env
var (default google:gemini-2.0-flash-lite,google:gemini-2.5-flash β separate Gemini capacity
pools) on any ModelAPIError, which covers 503s. Only the judge gets the fallback chain; the
agent-under-test's own model is untouched, since decoupling the judge from prod is the whole point
(evals.md Β§6).
FallbackModel wrapping all three tiers, then made a live judge call end-to-end and got a
correct verdict back.uv run python -m evals.run teaches us
something β a new regression caught, a threshold set from variance data, or a CI gate landing in Phase 4.
| What | Command |
|---|---|
| Full suite, prod model | cd backend && uv run python -m evals.run |
| One case | uv run python -m evals.run --case greek_island_hop_single_plan |
| Variance study | uv run python -m evals.run --repeat 3 --concurrency 3 |
| Override the judge model | uv run python -m evals.run --judge-model google:gemini-2.5-flash-lite |
| Live results dashboard | uv run python -m evals.dashboard β http://localhost:8420 |
| Skip Langfuse dataset run | uv run python -m evals.run --no-langfuse-dataset |
GOOGLE_API_KEY).
The data layer (flights/hotels/research) is faked, so no paid third-party API calls are made.
artifacts/ (the JSON reports) is git-ignored β reports stay local; this page is the durable
record of what they found.