Bilal Tahseen
All posts

I Won't Ship an AI Agent Without a Golden-Set Eval Gate

· Bilal Tahseen

I watched a team ship an agent that nailed the demo. Clean tool calls, coherent responses, perfect adherence to the happy path. Two weeks later, a prompt tweak to handle a new edge case silently broke three workflows clients relied on. Nobody caught it because there was no frozen eval suite, no score threshold, no gate that said "this build doesn't ship."

The team had vibes. They had spot checks. They had a demo script they ran before every deploy. What they didn't have was a versioned golden set with expected outcomes and a release gate that blocked deploys when scores dropped. The agent looked good in testing because testing was whatever someone remembered to try that afternoon.

That's not evaluation. That's theater. Real eval means frozen cases, reproducible scores, and a threshold you can't cross without breaking the build. If your agent can ship because the demo felt good, you don't have quality control—you have hope and a prayer.

Why demo scripts and spot checks fail

Demo days cherry-pick happy paths. Someone prepares three prompts that showcase the agent's strengths, runs them in front of stakeholders, and calls it validated. Then production traffic hits: adversarial inputs, ambiguous requests, tool failures, context that exceeds the window, users who phrase things differently than the demo script assumed.

I've debugged agents that passed every manual spot check and still failed in the wild because:

  • The demo used a customer name that matched the database perfectly; real traffic included typos, nicknames, and partial matches the agent couldn't resolve
  • The spot check tested a single-step workflow; production chained three tools together and the agent lost context halfway through
  • The demo assumed clean JSON from external APIs; live data included nulls, unexpected fields, and rate limits the agent never practiced handling
  • Someone tweaked the system prompt to handle a new feature and accidentally regressed two existing capabilities nobody thought to retest

Spot checks test what you remember to test. Production traffic tests everything you forgot. You can't rely on human memory to catch regressions across dozens of intents, tool combinations, and failure modes. You need a suite that runs the same cases every time and fails loudly when something breaks.

What a golden set is

A golden set is a versioned collection of fixed test cases with expected outcomes. Each case defines an input (user message, conversation history, available tools) and an expected result: the final answer, the tool calls that should fire, the arguments passed, the entities resolved, the side effects triggered.

It's not a spreadsheet someone updates after demos. It's code. Version controlled. Reviewed like code. Extended when new capabilities ship or bugs surface. Treated as the source of truth for whether an agent build is allowed to reach users.

Architecture flow from agent under test through tool runner, golden-set fixtures, scorer (exact match, rubric, tool-trace), scorecard, and release gate to ship or block

A minimal golden case includes:

  • Input: The prompt, conversation context, user metadata, available tools
  • Expected output: The final response (exact or rubric-scored), the tool trace (which tools were called, in what order, with what arguments), the final state (which records were created, updated, deleted)
  • Scoring criteria: How to judge success—exact match for structured outputs, rubric for open-ended responses, tool-trace validation for workflow correctness
  • Metadata: When the case was added, which feature or bug it covers, pass/fail history

The suite runs against every build. If a new prompt, model, tool schema, or retrieval strategy changes behavior, the golden set catches it before deploy. You don't ship and hope. You ship when scores pass threshold.

Scoring layers: exact match, rubric, and tool-trace

Not every output has a single correct answer. Some queries need exact matches. Some need human-like judgment. Some need proof the agent called the right tools in the right order. A production-grade eval harness layers three scoring modes.

Exact match for structured outputs

When the expected answer is deterministic—fetching a customer ID, parsing a date, classifying an intent, returning a specific database record—exact match scoring works. The golden case stores the expected output. The scorer compares strings, normalized JSON, or entity IDs. Pass or fail.

This catches regressions like:

  • The agent used to return {"status": "active"} and now returns {"status": "pending"} for the same input
  • A retrieval tweak changed which document is ranked first, breaking a workflow that depended on stable ordering
  • A prompt rewrite shifted how the agent formats timestamps, causing downstream parsers to fail

Exact match scoring is brittle by design. If the output changes, the test fails. That's the point. You update the golden case if the change is intentional. Otherwise, you fix the regression.

Rubric scoring for open-ended responses

Customer-facing responses, summarization, email drafts, and other natural language outputs don't have one correct answer. You can't exact-match a support reply. But you can define a rubric: does the response answer the question, maintain the right tone, avoid hallucinations, include required disclosures, stay within policy boundaries?

Rubric scoring uses an LLM-as-judge. The scorer receives the input, expected criteria, and actual output. It rates on a scale (1-5) or binary pass/fail. You aggregate scores across cases and set a minimum acceptable score for the suite.

I've used rubrics to catch:

  • An agent that started apologizing excessively after a prompt tweak, tanking user satisfaction
  • Responses that were factually correct but violated brand voice guidelines the exact-match suite couldn't encode
  • Summaries that omitted key details because a retrieval change surfaced less relevant chunks

Rubric scoring introduces variance. The same output might score differently across runs because the judge model isn't deterministic. You mitigate this with temperature zero, multiple judge runs, or human spot-checks on disagreements. It's not perfect. It's better than vibes.

Tool-trace validation for workflow correctness

The final answer can look right while the path to get there was wrong. An agent might return the correct customer record but only because it queried the entire table instead of using the index. Or it sends the right email but calls the API twice, burning quota. Or it updates the correct row after three failed attempts that should've been caught earlier.

Tool-trace scoring validates the workflow: which tools fired, in what order, with what arguments. The golden case encodes the expected sequence. The scorer compares actual tool calls against the trace.

interface GoldenToolTrace {
  calls: Array<{
    tool: string;
    args: Record<string, unknown>;
    minPosition?: number;
    maxPosition?: number;
  }>;
  forbiddenTools?: string[];
}

Example: "The agent should call search_customer(query='john@example.com') then fetch_customer_record(id=<result>). It should not call list_all_customers or attempt writes."

Tool-trace mismatches are often more serious than output errors. If the agent hallucinates a tool argument but the contract layer rejects it, that's caught in logs. If the agent calls a delete tool when it should've called read, that's a critical failure that might bypass validation and corrupt state.

I treat critical tool-trace failures—calling forbidden tools, passing obviously wrong arguments, skipping required workflow steps—as hard blockers. A build doesn't ship if it breaks these, regardless of aggregate score.

The decision tree: golden set gates the ship decision

Not every build needs full eval if nothing changed. But once eval runs, the gate enforces thresholds.

Decision tree requiring a golden set, running the eval suite, shipping only when score clears threshold with no critical failures, always blocking on critical tool-trace mismatch

No golden set available? Block the ship. You're flying blind. Build the suite before you deploy the agent. Start with ten high-value cases. Add coverage as you go. But don't ship without baseline eval.

Golden set exists? Run the suite. Every prompt change, model swap, tool schema update, retrieval tweak, or dependency upgrade triggers a full eval run. No shortcuts.

Score below threshold or critical failure? Block and open a failure triage ticket. Review failing cases. Determine if the regression is real or if the golden set needs updating. Fix the agent or update expectations. Don't lower the threshold to make the build pass.

Score at or above threshold and no critical failures? Ship to limited traffic. I don't go straight to 100% rollout. The golden set tests known cases. Production always surfaces unknowns. A gradual rollout with monitoring catches issues the eval missed.

Critical tool-trace mismatch? Always block. If the agent tries to call a forbidden tool, hallucinates arguments that would corrupt state, or skips a required safety check, that's a showstopper. Fix it before any traffic sees the build.

The gate is automatic. Humans can override in emergencies, but every override is logged and requires post-mortem review. The default is: score too low, build doesn't ship.

Implementation sketch: not a drop-in product

This isn't a starter kit. It's the architecture I've built when shipping agents where regressions cost real money. Your stack will differ. The concepts stay the same.

Golden case schema

interface GoldenCase {
  id: string;
  description: string;
  input: {
    userMessage: string;
    conversationHistory?: Message[];
    availableTools: string[];
    userContext?: Record<string, unknown>;
  };
  expected: {
    finalResponse?: string;
    rubricCriteria?: string[];
    toolTrace?: GoldenToolTrace;
    finalState?: Record<string, unknown>;
  };
  scoring: {
    mode: 'exact' | 'rubric' | 'tool_trace' | 'hybrid';
    passingScore?: number;
    criticalFailureConditions?: string[];
  };
  metadata: {
    addedAt: string;
    tags: string[];
    coverageArea: string;
  };
}

EvalRunner

class EvalRunner {
  constructor(
    private agent: Agent,
    private goldenSet: GoldenCase[],
    private scorers: Map<string, Scorer>
  ) {}

  async run(): Promise<EvalReport> {
    const results: CaseResult[] = [];

    for (const testCase of this.goldenSet) {
      const agentOutput = await this.agent.execute({
        message: testCase.input.userMessage,
        history: testCase.input.conversationHistory,
        tools: testCase.input.availableTools,
        context: testCase.input.userContext,
      });

      const scorer = this.scorers.get(testCase.scoring.mode);
      const score = await scorer.score(testCase, agentOutput);

      results.push({
        caseId: testCase.id,
        passed: score.passed,
        score: score.value,
        criticalFailure: score.criticalFailure,
        details: score.details,
      });
    }

    return this.aggregateResults(results);
  }

  private aggregateResults(results: CaseResult[]): EvalReport {
    const totalCases = results.length;
    const passedCases = results.filter((r) => r.passed).length;
    const criticalFailures = results.filter((r) => r.criticalFailure);
    const passRate = passedCases / totalCases;

    return {
      totalCases,
      passedCases,
      passRate,
      criticalFailures: criticalFailures.length,
      recommendation:
        criticalFailures.length > 0 ? 'BLOCK' :
        passRate >= this.minPassRate ? 'SHIP' : 'BLOCK',
      failedCases: results.filter((r) => !r.passed),
    };
  }
}

Scorer interfaces

interface Scorer {
  score(testCase: GoldenCase, agentOutput: AgentOutput): Promise<ScoringResult>;
}

class ExactMatchScorer implements Scorer {
  async score(testCase: GoldenCase, agentOutput: AgentOutput): Promise<ScoringResult> {
    const expected = testCase.expected.finalResponse;
    const actual = agentOutput.finalResponse;
    const passed = this.normalize(expected) === this.normalize(actual);

    return {
      passed,
      value: passed ? 1 : 0,
      criticalFailure: false,
      details: passed ? 'Exact match' : `Expected: ${expected}, Got: ${actual}`,
    };
  }

  private normalize(text: string): string {
    return text.trim().toLowerCase().replace(/\s+/g, ' ');
  }
}

class RubricScorer implements Scorer {
  constructor(private judgeModel: LLM) {}

  async score(testCase: GoldenCase, agentOutput: AgentOutput): Promise<ScoringResult> {
    const prompt = this.buildJudgePrompt(testCase, agentOutput);
    const judgment = await this.judgeModel.complete(prompt);
    const scoreMatch = judgment.match(/score:\s*(\d+)/i);
    const score = scoreMatch ? parseInt(scoreMatch[1]) / 5 : 0;
    const passed = score >= (testCase.scoring.passingScore || 0.8);

    return {
      passed,
      value: score,
      criticalFailure: false,
      details: judgment,
    };
  }

  private buildJudgePrompt(testCase: GoldenCase, agentOutput: AgentOutput): string {
    return `Evaluate the following agent response against criteria.

Input: ${testCase.input.userMessage}
Criteria: ${testCase.expected.rubricCriteria?.join(', ')}
Response: ${agentOutput.finalResponse}

Rate 1-5 and explain. Output "Score: X" on its own line.`;
  }
}

class ToolTraceScorer implements Scorer {
  async score(testCase: GoldenCase, agentOutput: AgentOutput): Promise<ScoringResult> {
    const expected = testCase.expected.toolTrace;
    const actual = agentOutput.toolCalls;

    if (!expected) {
      return { passed: true, value: 1, criticalFailure: false, details: 'No trace expected' };
    }

    const forbiddenCalled = expected.forbiddenTools?.some((tool) =>
      actual.some((call) => call.tool === tool)
    );

    if (forbiddenCalled) {
      return {
        passed: false,
        value: 0,
        criticalFailure: true,
        details: 'Critical: Forbidden tool called',
      };
    }

    const allExpectedCalled = expected.calls.every((expectedCall) =>
      actual.some(
        (actualCall) =>
          actualCall.tool === expectedCall.tool &&
          this.argsMatch(expectedCall.args, actualCall.args)
      )
    );

    return {
      passed: allExpectedCalled,
      value: allExpectedCalled ? 1 : 0,
      criticalFailure: false,
      details: allExpectedCalled
        ? 'Tool trace matches'
        : 'Missing or incorrect tool calls',
    };
  }

  private argsMatch(expected: Record<string, unknown>, actual: Record<string, unknown>): boolean {
    // Implement deep comparison logic or partial match as needed
    return JSON.stringify(expected) === JSON.stringify(actual);
  }
}

ReleaseGate

interface ReleaseGateConfig {
  minPassRate: number;
  allowCriticalFailures: boolean;
}

class ReleaseGate {
  constructor(private config: ReleaseGateConfig) {}

  evaluate(report: EvalReport): GateDecision {
    if (report.criticalFailures > 0 && !this.config.allowCriticalFailures) {
      return {
        decision: 'BLOCK',
        reason: `${report.criticalFailures} critical failure(s)`,
        report,
      };
    }

    if (report.passRate < this.config.minPassRate) {
      return {
        decision: 'BLOCK',
        reason: `Pass rate ${report.passRate.toFixed(2)} below threshold ${this.config.minPassRate}`,
        report,
      };
    }

    return {
      decision: 'SHIP',
      reason: 'All checks passed',
      report,
    };
  }
}

Run this on every candidate build. Log results. Block deploys when the gate says no. Update the golden set when new features ship or bugs are fixed. Treat eval like CI: it's not optional, and failing tests block merges.

Failure modes I've seen

Golden sets rot if you don't maintain them. Cases become stale. Expected outputs drift from reality. Rubric criteria stop matching policy. You end up testing against outdated assumptions.

Teaching to the test. Teams optimize agents to pass the golden set instead of solving the underlying task. The eval suite becomes a set of memorized paths the agent learned to navigate, but real traffic still fails.

Flaky scorers. Rubric judges give inconsistent scores. Tool-trace validators fail because argument order changed but semantics didn't. You burn time debugging eval infrastructure instead of improving the agent.

Over-fitting to fixtures. The golden set covers happy paths. Production serves edge cases. The agent passes eval and still fails in the wild because the suite didn't include enough adversarial examples, malformed inputs, or rare workflows.

Skipping tool-trace checks. Teams only validate final outputs. The agent returns correct answers through terrible workflows—burning tokens, hitting rate limits, calling expensive APIs redundantly. The scorecard says green. The cost dashboard says red.

Treating demo transcripts as the suite. Someone exports a demo conversation, marks the agent's responses as "expected," and calls it eval. The next build passes because it mimics the demo. Then production traffic hits and the agent fails on anything outside the script.

Lowering thresholds to ship. A build drops the pass rate from 95% to 88%. Instead of fixing regressions, someone lowers the gate threshold to 85% "temporarily." The threshold never goes back up. Quality decays.

Guard against these by:

  • Reviewing golden set updates in code review like you review features
  • Adding adversarial cases whenever a production bug surfaces
  • Running eval in CI with the same rigor as unit tests
  • Auditing scorecard trends over time—if pass rates drift down, investigate
  • Keeping tool-trace checks mandatory for any case involving writes or external side effects

Close

If you can ship an agent because the demo felt good, you don't have eval—you have theater. Tool contracts validate call structure. Shadow mode gates writes. Golden-set eval gates whether the build is allowed to ship at all.

You need frozen cases, reproducible scores, and a release gate that blocks when quality drops. Exact match for structured outputs. Rubric scoring for natural language. Tool-trace validation for workflows. Aggregate scores into a pass rate, enforce a threshold, treat critical failures as hard blocks.

Spot checks test what you remember. Production tests what you forgot. The golden set is your memory, version controlled, executable, and enforced before every deploy. Build it before you ship. Run it on every build. Block when it fails. Update it when behavior intentionally changes.

I've debugged enough post-deploy disasters to know: the failure is rarely the agent. It's the missing gate. The missing suite. The missing threshold that says "this doesn't ship." Add the gate before you ship the agent.

I work with teams building production AI systems where quality gates aren't optional. If you're working on agent eval infrastructure, orchestration systems, or anything in this space, reach out: bilaltehseen@gmail.com or check out my hire page.

Building something with AI?

I help teams ship production AI agents, retrieval systems, and document intelligence. Let's talk about yours.