Eval-Driven Development for a .NET Agent: Replaying Traces Instead of Re-Calling the Model A .NET developer documented porting Alex Punnen's Python eval-driven development stack, published on Towards AI, to a fully local .NET agent setup using Ollama for both the agent and judge models and a stock OpenTelemetry Collector in Docker writing traces to a plain JSON lines file. The three-layer contract separates live deterministic runs (agent and tool calls repeated N times, minutes per run), zero-model-call trace replay (disk read and parsing, seconds per run), and a sampled LLM judge that makes judge model calls only on a sample. The author notes trace replay cannot show whether a new prompt is better because stored traces came from the old prompt, and reports that the judge confidently passed an answer that would have made an incident worse. The moment I decided to take evals on my .NET agent seriously was not dramatic. I changed one sentence in a system prompt, ran the agent by hand four times, liked what I saw, and shipped it. Two days later I wanted to know whether that change had made anything worse. The only way I had to find out was to run the agent again, against a model, and squint at the output. That is the expensive way to answer a cheap question. Most of what I wanted to know “did it still call the status tool?”, “did it stop looping?”, “does the answer still mention the service name?” was already sitting in traces I had thrown away. A few weeks ago Alex Punnen published a piece on Towards AI called Eval-Driven Development: A Software Engineering Approach to Production-Grade AI Agents , with a companion repo. His stack is Python: Pydantic AI, OpenTelemetry, Grafana Tempo, SeaweedFS and Pydantic Evals. The part that clicked for me was not any single tool. It was the shape: This article is my port of that shape to .NET, kept fully local: Ollama for both the agent and the judge, and a stock OpenTelemetry Collector in Docker writing traces to a plain JSON lines file. I’ll also show the part I am least proud of, which is the judge confidently passing an answer that would have made an incident worse. One note before starting. Everything I describe about Punnen’s setup comes from his article and his repo README. Where I quote numbers from anyone else’s write-up, I label them as theirs. I have not verified them. Here is the summary I wish I had pinned above my desk before I started. The contract sits on top of all three. +---------------------------+-----------------------+-------------+-------------------------------------------+| Layer | Cost per run | Speed | What it catches |+---------------------------+-----------------------+-------------+-------------------------------------------+| 1. Live deterministic | Agent model calls + | Slow | Flaky tool use, loops, missing facts in || repeat N times | tool calls, x N | minutes | the answer, regressions from a NEW prompt || | | | or model version |+---------------------------+-----------------------+-------------+-------------------------------------------+| 2. Trace replay, | Zero model calls. | Fast | Production traffic violating the current || deterministic | Disk read + parsing | seconds | contract, new checks applied to history, || | | | drift in tool-call counts over time |+---------------------------+-----------------------+-------------+-------------------------------------------+| 3. Trace replay, | Judge model calls | Medium | Wrong-but-plausible answers, ignored tool || sampled LLM judge | only, on a sample | | evidence, tone and safety issues that no || | | | string check can see |+---------------------------+-----------------------+-------------+-------------------------------------------+ The thing that took me too long to understand is what layer 2 does not do. Replaying stored traces cannot tell you whether your new prompt is better. The traces were produced by the old prompt. If you want to test a change, you run layer 1. What replay gives you is the ability to ask new questions of old behavior for free, and to check real traffic against your contract without paying for it twice. I’ll come back to this, because I got it wrong at first. Anthropic’s engineering post Demystifying evals for AI agents gave me words for things I was doing badly. A few of their definitions matter here this is their framework from their own blog, and I find it useful, not something I have validated independently : They also make a point that made me uncomfortable: grading the exact path an agent took tends to produce brittle tests, and it is usually better to grade what the agent produced. Min and max tool-call checks are path checks. I kept them anyway, but I treat them as bounds , never as a required sequence. “Called at least one tool and at most five” survives an agent that finds a smarter route. “Called get service status then search runbook" does not. So in their terms: my contract file defines tasks, each live run is a trial, each stored OpenTelemetry trace is a transcript, and the three layers above are code graders layers 1 and 2 plus a sampled model grader layer 3 . The human grader is me, and that turned out to be a design problem of its own. The agent is deliberately small. It is an on-call helper with two tools: one returns the current status of a service, one searches a tiny runbook. It runs on Microsoft.Extensions.AI MEAI with OllamaSharp, so the same code would run against a hosted model by swapping one line. Layout: eval-dotnet/ docker-compose.yml otel-collector.yaml traces/ <- collector writes traces.jsonl here contracts/ incident-agent.v3.json incident-agent.v4.json Shared/ Contract.cs TraceRecord.cs DeterministicChecks.cs EvalAgent/ <- runs the agent, live checks layer 1 EvalReplay/ <- replays traces, judge sampling layers 2 and 3 I started by copying Punnen’s Tempo plus SeaweedFS setup and then simplified. For a regression corpus I wanted something I could grep, diff and commit. The OpenTelemetry Collector's file exporter writes every batch as one line of OTLP JSON, which is exactly that. docker-compose.yml: services: ollama: image: ollama/ollama ports: - "11434:11434" volumes: - ollama:/root/.ollamaotel-collector: image: otel/opentelemetry-collector-contrib:latest pin a version for real use command: "--config=/etc/otelcol/config.yaml" user: "0:0" so it can write to ./traces volumes: - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro - ./traces:/traces ports: - "4317:4317" OTLP gRPC - "4318:4318" OTLP HTTPvolumes: ollama: {} otel-collector.yaml: receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318processors: batch: {}exporters: file: path: /traces/traces.jsonl debug: verbosity: basicservice: pipelines: traces: receivers: otlp processors: batch exporters: file, debug Bring it up and pull two models from different families, one for the agent and one for the judge: mkdir -p traces contractsdocker compose up -ddocker exec -it $ docker compose ps -q ollama ollama pull qwen3:8bdocker exec -it $ docker compose ps -q ollama ollama pull llama3.1:8b I use qwen3:8b for the agent because it handles tool calling reasonably at that size, and llama3.1:8b as the judge. Using a judge from a different model family than the agent is my own habit, not something I have measured the benefit of. One trade-off worth naming: Punnen’s Tempo config uses a 24 hour block retention. That is fine for observability. It is not fine for a regression corpus. A trace that disappears tomorrow is not capital. I let the collector write everything, and when a trace is interesting a failure, a weird edge case, a case I hand-labeled I copy that line into a golden/ file in the repo. Those never expire. The contract is the piece I copied most directly. One file holds the agent identity, the version, the model, the system prompt, the cases and their checks. When I change the prompt, I bump the version and create a new file. Old traces carry the version they were produced under, so replay can look up the matching contract. contracts/incident-agent.v3.json: { "agentId": "incident-agent", "version": 3, "model": "qwen3:8b", "judgeModel": "llama3.1:8b", "systemPrompt": "/no think You are an on-call assistant. Always check the current status of a service with get service status before giving advice, and use search runbook for procedures. Be brief. If you do not know, say so.", "judgeDimensions": { "name": "helpfulness", "question": "On a scale of 1 to 5, how helpful and complete is this answer for an on-call engineer? PASS if 4 or higher." } , "cases": { "id": "payments-latency", "input": "payments-api latency alert just fired. What should I do?", "checks": { "contains": "payments-api" , "maxModelRequests": 4, "minToolCalls": 1, "maxToolCalls": 4 } }, { "id": "search-healthy", "input": "Is search-api OK right now?", "checks": { "contains": "search-api" , "maxModelRequests": 3, "minToolCalls": 1, "maxToolCalls": 2 } }, { "id": "unknown-service", "input": "inventory-api is throwing 500s, help", "checks": { "contains": "inventory-api" , "maxModelRequests": 3, "minToolCalls": 1, "maxToolCalls": 3 } } } That judgeDimensions block is the v3 rubric. Remember it. It is the one that failed me. The /no think at the start of the prompt is Qwen3's soft switch to skip the visible reasoning block. Without it, my contains checks were sometimes passing because the service name appeared in the model's thinking rather than in the answer. The same check code has to run against live activities and against JSON read back from disk, so I normalize both into one record. Put these three files in Shared/. Shared/Contract.cs: using System.Text.Json;namespace EvalShared;public sealed record CaseChecks List