Summary
Build the agent eval substrate so just eval evals/cases/<suite>.yaml runs a real suite end-to-end against the live stack: hits /agent/ask, captures trace_ids, runs probes against otel.otel_traces, persists per-probe JSON results + a summary, and prints pass/fail counts (with a regression diff against committed baselines).
Architectural decision recorded in ADR 14. Foundation comes from ADR 13 (OTel + Langfuse) plus Ship 1.5 (mcae.* namespace + enriched gate/claim/primitive spans, commits 1177c27 and 6d81e6b).
The four invariants this ship protects
- A case is data, not code (YAML, loadable into a typed schema, stable IDs).
- A probe is a predicate over an OTel trace (pure function, no framework imports).
- A probe result is a structured artifact (JSON, queryable in ClickHouse, schema independent of any tool).
- The agent under test is invoked exactly the way production invokes it (
/agent/ask over HTTP, real OTel pipeline, real mcae.* and gen_ai.* spans).
The four-layer stack
Layer 4: Framework adapter (THIN, SWAPPABLE) ~80 LOC, pydantic_evals seam
Layer 3: Runner (FRAMEWORK-AGNOSTIC) ~50 LOC, orchestration
Layer 2: Probes (PURE FUNCTIONS, OURS) ~150 LOC, 5-7 probe kinds
Layer 1: Schema (CANONICAL, OURS) ~80 LOC, pydantic models
Lower layers know nothing about higher ones. Framework swap touches only Layer 4.
Probe vocabulary (initial)
has_matching_span(span_name, attrs)
tool_called_with_args(tool_name, arg_predicates) — reads gen_ai.tool.name + mcae.primitive.input
claim_grounded_in(source_kind) — reads mcae.claim.source_kind, anchor for future sql_explore hybrid
gate_passed(kind, version?) — reads mcae.gate.verdict + mcae.gate.version
span_latency_p50_under(span_name, ms) — aggregates OTel Duration
no_span_with_status(span_name, status)
llm_call_used_model(model_name) — reads gen_ai.request.model
New probe kinds extend the schema's Literal and add a file in agent_service/evals/probes/.
Sub-tickets (sequential, each lands as its own commit)
Done when
just eval evals/cases/wallet_profile_smoke.yaml returns a summary like 5/5 cases passed, 23/23 probes passed (vs baseline: +0 -0).
evals/runs/<run_id>/ populated with per-probe JSON.
- New probe kinds can be added by extending the schema
Literal + dropping one file in probes/.
- New cases are pure YAML edits.
- Layer 4 can be swapped (rewrite
pydantic_evals_adapter.py) without touching Layers 1-3.
Trigger conditions to revisit ADR 14
- pydantic_evals introduces a feature we want that does not map cleanly to our schema.
- pydantic_evals stagnates or pivots in a direction incompatible with our use.
- Anthropic, OpenAI, or another credible 2026+ source publishes an agent-eval pattern materially different from this design (verify CURRENT per AGENTS.md research-dating rule).
References
- ADR 14 — decision rationale and four invariants
- ADR 13 — OTel substrate this ship consumes
- AGENTS.md — library acceptance bar + research-dating rule + no-adapter-bridging rule
Summary
Build the agent eval substrate so
just eval evals/cases/<suite>.yamlruns a real suite end-to-end against the live stack: hits/agent/ask, captures trace_ids, runs probes againstotel.otel_traces, persists per-probe JSON results + a summary, and prints pass/fail counts (with a regression diff against committed baselines).Architectural decision recorded in ADR 14. Foundation comes from ADR 13 (OTel + Langfuse) plus Ship 1.5 (
mcae.*namespace + enriched gate/claim/primitive spans, commits 1177c27 and 6d81e6b).The four invariants this ship protects
/agent/askover HTTP, real OTel pipeline, realmcae.*andgen_ai.*spans).The four-layer stack
Lower layers know nothing about higher ones. Framework swap touches only Layer 4.
Probe vocabulary (initial)
has_matching_span(span_name, attrs)tool_called_with_args(tool_name, arg_predicates)— readsgen_ai.tool.name+mcae.primitive.inputclaim_grounded_in(source_kind)— readsmcae.claim.source_kind, anchor for futuresql_explorehybridgate_passed(kind, version?)— readsmcae.gate.verdict+mcae.gate.versionspan_latency_p50_under(span_name, ms)— aggregates OTelDurationno_span_with_status(span_name, status)llm_call_used_model(model_name)— readsgen_ai.request.modelNew probe kinds extend the schema's
Literaland add a file inagent_service/evals/probes/.Sub-tickets (sequential, each lands as its own commit)
Done when
just eval evals/cases/wallet_profile_smoke.yamlreturns a summary like5/5 cases passed, 23/23 probes passed (vs baseline: +0 -0).evals/runs/<run_id>/populated with per-probe JSON.Literal+ dropping one file inprobes/.pydantic_evals_adapter.py) without touching Layers 1-3.Trigger conditions to revisit ADR 14
References