AI Agents·9 min read·

Evaluate AI Agents With Traces, Not Vibes

Stop judging ops agents by one lucky demo. Score JSONL traces against golden tasks so regressions show up before they restart the wrong systemd unit.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

A demo is not an evaluation. Watching an agent restart nginx once on a Friday is how teams ship a loop that also restarts it on a cascade of 502s from the app. Evaluation for agents is replay: frozen observations, expected tool names, forbidden tools, and a trace you can diff. If you cannot say why last week's prompt change is better, you are tuning vibes.

This is not academic benchmarking. It is a pytest file and a directory of JSONL traces on the same Linux VPS that runs the bot. The model API is a dependency you stub for unit tests and call live for a small nightly set.

What a Trace Must Contain

Each turn is one JSON object: step, role, tool name, argument fingerprint, observation hash, tokens in, tokens out, latency, finish reason. Without argument fingerprints you cannot tell a restart of nginx from a restart of app. Without finish reason you cannot tell a real answer from a step-budget abort.

Write traces even when you stub tools. The loop should not know whether read_unit_status hit systemd or a fixture. That seam is what makes offline eval possible.

python
import json
import hashlib

def fp(value):
    blob = json.dumps(value, sort_keys=True, default=str).encode()
    return hashlib.sha256(blob).hexdigest()[:12]

def trace_turn(path, step, tool, args, observation, usage, reason):
    row = {
        "step": step,
        "tool": tool,
        "args_fp": fp(args),
        "obs_fp": fp(observation[:500]),
        "prompt_tokens": usage.get("prompt_tokens", 0),
        "completion_tokens": usage.get("completion_tokens", 0),
        "reason": reason,
    }
    with open(path, "a", encoding="utf-8") as handle:
        handle.write(json.dumps(row) + "\n")

Store the raw observation in a sidecar file if you need it for debugging. The scored artifact is the compact JSONL. Compact means you can keep months of evals on High-Speed SSD without drama.

Golden Tasks, Not Open-Ended Chats

Write tasks with a pass condition. Examples that pay rent:

  • nginx inactive, app health 200: expected tool restart_unit with unit nginx.service, no app restart.
  • both health endpoints 200: expected zero write tools, final message cites the 200s.
  • unknown unit name from the model: expected error string, no subprocess.
  • step budget 4, tool that always returns the same error: expected abort, not a 40-call bill.

Each task has a fixtures dict that the dispatch uses instead of the real world. Replay is deterministic if the model is stubbed. When you include a live model, run three times and record pass rate, not a single screenshot.

python
GOLDEN = [
    {
        "id": "restart-nginx-only",
        "fixtures": {
            "read_unit_status": "ActiveState=inactive",
            "http_get_health": "status=200 body=ok",
        },
        "expect_tools": ["read_unit_status", "http_get_health", "restart_unit"],
        "forbid_tools": ["restart_unit:app.service"],
        "must_finish": "stop",
    },
    {
        "id": "healthy-no-writes",
        "fixtures": {
            "read_unit_status": "ActiveState=active",
            "http_get_health": "status=200 body=ok",
        },
        "expect_tools": ["read_unit_status", "http_get_health"],
        "forbid_tools": ["restart_unit", "reload_unit"],
        "must_finish": "stop",
    },
]

def score(trace_rows, task):
    names = [row["tool"] for row in trace_rows if row.get("tool")]
    for needed in task["expect_tools"]:
        if needed not in names:
            return False, "missing " + needed
    for banned in task["forbid_tools"]:
        if banned in names:
            return False, "forbid " + banned
    if trace_rows[-1].get("reason") != task["must_finish"]:
        return False, "bad finish"
    return True, "ok"

Start with ten tasks. If you cannot write ten, you do not know what the agent is for. Resist a huge suite of vague "be helpful" items. Those are vibes with YAML.

Nightly Live, Always Offline

Offline tests run in CI with a recorded model response or a fake that emits a fixed tool_calls payload. They catch dispatch bugs, schema breaks, and guardrail regressions without spending tokens.

Live tests run from a systemd timer at night against the real API with fixtures still in place for the tools. They catch prompt drift and provider format changes. Cap them. A nightly bill should be smaller than a single confused production run.

When a live test fails, keep the trace. Diff it with the last pass. The diff is the review. Do not accept "it felt better after we added a sentence to the system prompt" as a merge reason.

Intel Xeon Platinum on a Lucknow VPS will run pytest in seconds. The bottleneck is the model API, which is why fixtures exist. You do not need a GPU SKU and you do not need a cluster to evaluate a bot that has six tools.

Version the golden tasks with the prompt. A prompt change that is not a code change still needs a trace diff. Store the system prompt hash in the trace header so you can group failures by prompt, not by vibes. If pass rate on the live nightly set drops, revert the prompt the same way you revert a bad nginx config: last known good file, then investigate. High-Speed SSD holds the JSONL; git holds the tasks; neither is optional.

Trace-based agent evaluation Golden task fixtures + expect/forbid Loop + stub tools JSONL trace written Score pass or named miss Two lanes Offline CI: fake model payload, free, catches code bugs Nightly live: real API, fixtures still on, cap the bill Keep failing traces; diff them with the last pass

Takeaway

If you cannot score a trace against a golden task, you are not evaluating the agent. You are watching it. Run pytest on a Netbay Linux VPS in Lucknow and keep the JSONL next to the bot at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance