Eval-driven development Β· a living log

What we learned by measuring the agent instead of vibing it

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.

30
Golden cases
7
Categories Β· 10 evaluators
4
Real regressions caught
99.3%
Executed assertions, latest full run
Why this page exists

"It got better" is a vibe. A pass-rate delta is a 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.

🩻

Blind spots, made visible

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.

🎯

Attributable, not anecdotal

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."

🧯

Root cause over prompt-pleading

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.

The log

Every discovery, in order

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.

Phase 1 Β· 2026-07-04 Making the agent evaluable headless nearly broke on a tool that doesn't exist headless

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.

Phase 2 Β· 2026-07-04 First golden baseline: 12 cases, 6 categories, 93.8% β€” and it immediately paid for itself

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.

Fixed Β· 2026-07-04 Headline finding: Flash can gather six flight/hotel line items correctly and still fail to add them up

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%.

Why it matters: the assertion flipped from a model signal into a pipeline invariant guard β€” it now exists to catch anyone who accidentally removes the normalization, which is exactly what a regression test should do.
Noted Β· 2026-07-04 Fixing one thing surfaced the next: heavy single-plan synthesis is flaky

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.

Phase 3 Β· 2026-07-05 Dataset grown 12 β†’ 30 cases; the suite learned to judge things a Python 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.

Noted Β· 2026-07-05 A "28 cases failed" run taught a meta-lesson: don't trust a red run before you've ruled out infra

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.

Isolated Β· 2026-07-05 After removing the noise: a clean, real signal β€” two heavy-synthesis cases genuinely fail

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.

Open Β· 2026-07-05 Tried the obvious fix; it wasn't enough β€” logged as open, not papered over

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.

Improved Β· 2026-07-05 Made the eval config itself a first-class, observable setting

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.

Fixed Β· 2026-07-05 Three fix-verify loops off one full run: an evaluator false-negative, an ambiguous prompt, and a real fabrication bug

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.

Also surfaced, still open: 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.
Shipped Β· 2026-07-05 Langfuse wired into the eval suite β€” eval traces now tell themselves apart from live traffic

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.

Why it matters: every case in the entry above was root-caused with a throwaway _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.
Debugged via Langfuse Β· 2026-07-05 First real debugging session using the new Langfuse wiring β€” and it paid off in two clicks, not a custom script

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.

Bonus finding β€” revised, not resolved: one of those 9 repeats hit the known-open 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.
Shipped Β· 2026-07-05 Langfuse dataset runs β€” eval scores are now browsable version-over-version, not just per-run

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.

Verified live, not just wired: ran a single-case 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.
Verified Β· 2026-07-05 First full 30-case run through the dataset-run publisher β€” clean, plus one Langfuse tidiness fix

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.

Tidiness fix the run surfaced: the dataset run and per-case eval traces were all correctly tagged 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.
Fixed Β· 2026-07-05 Dataset runs were API-visible but the Langfuse Experiments tab was empty β€” made each case a real experiment item

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.

Fix: replicate exactly what 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.
Open Β· 2026-07-05 Migrating the agent's model to a cheaper tier paid for itself β€” and cost one honest regression

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.

Not just faster and cheaper β€” the same A/B caught a real regression: 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.
Fixed Β· 2026-07-06 The judge itself started 503ing under load β€” wrapped it in a fallback chain instead of hoping it clears up

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).

Verified, not just wired: confirmed the judge agent resolves to a FallbackModel wrapping all three tiers, then made a live judge call end-to-end and got a correct verdict back.
Next entry TBD. This section grows every time 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.
Reproduce it yourself

Every claim above is one command away

WhatCommand
Full suite, prod modelcd backend && uv run python -m evals.run
One caseuv run python -m evals.run --case greek_island_hop_single_plan
Variance studyuv run python -m evals.run --repeat 3 --concurrency 3
Override the judge modeluv run python -m evals.run --judge-model google:gemini-2.5-flash-lite
Live results dashboarduv run python -m evals.dashboard β†’ http://localhost:8420
Skip Langfuse dataset runuv run python -m evals.run --no-langfuse-dataset
Cost/keys. Real runs call Gemini and spend tokens (needs 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.