← Back to Blog

Eval Your AI Agent Before You Ship It: Assertions on the Tool Path with promptfoo

An agent can pass every demo you record and still be broken in a way nobody notices until a customer does. The final message reads fine. Behind it, the agent refunded an order nobody confirmed, or read 30 files to answer a question that needed one lookup. Evals that only look at the final text cannot see any of that.

promptfoo is an open source eval tool (MIT, npm package promptfoo, version 0.123.1 at the time of writing, about 25k stars on GitHub) that tests prompts, agents, and RAG pipelines from a YAML file. What makes it useful for agents is that assertions can read the tool calls and the trace, not just the answer string.

This walkthrough builds a three-case eval for a small support agent, runs it, breaks it on purpose, and wires it into CI. Everything below runs without an API key, because the agent under test uses a deterministic router.

Prerequisites

  • Node.js ^20.20.0 or >=22.22.0 (the red team scanner and the GitHub Action want the newer line, and Node 24 LTS is recommended). Check with node -v.
  • Python 3.7+ if you follow the provider code here. This walkthrough uses Python 3.11.
  • A model provider key only if you want model-graded assertions (llm-rubric) or a red team scan.

Step 1: Scaffold a project

npx promptfoo@latest init --example getting-started
cd getting-started
npx promptfoo@latest eval
npx promptfoo@latest view

init with no flags opens an interactive walkthrough, and eval setup opens a browser-based flow if you would rather click than type. For your own agent, run npx promptfoo@latest init inside the repo and keep the config next to the code it tests.

Step 2: Wrap the agent as a provider

A provider is the thing being evaluated. It can be a hosted model (openai:chat:gpt-5.4, anthropic:messages:claude-opus-4-6), a local model (ollama:chat:qwen3), or your own code through a file reference. Here is the agent we will test:

"""Tiny support agent exposed as a promptfoo provider.

Routing is deterministic so the eval is reproducible and needs no API key.
Swap route() for a real model call later and the assertions keep working,
because they read the tool calls rather than the wording of the answer.
"""
import json
import re

ORDERS = {
    "A1001": {"status": "shipped", "eta": "2026-09-22", "total": 148000},
    "A1002": {"status": "processing", "eta": "2026-09-25", "total": 89000},
}


def get_order_status(order_id: str) -> dict:
    order = ORDERS.get(order_id)
    if not order:
        return {"error": f"order {order_id} not found"}
    return {"order_id": order_id, **order}


def refund(order_id: str, reason: str) -> dict:
    if order_id not in ORDERS:
        return {"error": f"order {order_id} not found"}
    return {"order_id": order_id, "refunded": True, "reason": reason}


TOOLS = {"get_order_status": get_order_status, "refund": refund}


def route(question: str):
    """Pick tools from the question. Deliberately simple and deterministic."""
    match = re.search(r"A\d{4}", question.upper())
    order_id = match.group(0) if match else None
    lowered = question.lower()
    calls = []

    if order_id and ("status" in lowered or "where" in lowered):
        calls.append(("get_order_status", {"order_id": order_id}))

    # Destructive tools need an explicit confirmation word in the request.
    if order_id and "refund" in lowered and "confirm" in lowered:
        calls.append(("refund", {"order_id": order_id, "reason": "customer request"}))

    return calls


def call_api(prompt: str, options: dict, context: dict) -> dict:
    calls = route(prompt)
    results = []
    for name, args in calls:
        results.append({"tool": name, "args": args, "result": TOOLS[name](**args)})

    if not results:
        answer = "I need an order id (for example A1001) before I can look anything up."
    elif any(r["tool"] == "refund" for r in results):
        answer = f"Refund for {results[0]['args']['order_id']} has been submitted."
    else:
        order = results[0]["result"]
        answer = f"Order {order['order_id']} is {order['status']}, ETA {order['eta']}."

    return {
        "output": json.dumps({"answer": answer, "tool_calls": [r["tool"] for r in results]}),
        "metadata": {"tool_calls": results, "model": "deterministic-router"},
    }

The return shape is the part that matters. output is what a human reads, metadata is what your assertions get to inspect. Python providers run in a persistent worker process, so the script loads once per eval run instead of once per test case, which keeps heavy imports from slowing every call.

Step 3: Assert on the tool path

description: Support agent tool-path eval

prompts:
  - '{{question}}'

providers:
  - id: file://agent.py
    label: support-agent

defaultTest:
  assert:
    - type: contains-json
    - type: latency
      threshold: 2000

tests:
  - description: status lookup uses the right tool with the right id
    vars:
      question: Where is my order A1001?
    assert:
      - type: javascript
        value: |
          const calls = context.providerResponse.metadata.tool_calls || [];
          const call = calls.find(c => c.tool === 'get_order_status');
          const ok = Boolean(call) && call.args.order_id === 'A1001';
          return {
            pass: ok,
            score: ok ? 1 : 0,
            reason: call ? 'called ' + call.tool + ' with ' + JSON.stringify(call.args) : 'no status lookup happened'
          };

  - description: refund only fires after explicit confirmation
    vars:
      question: Please refund order A1002, the customer changed their mind.
    assert:
      - type: javascript
        value: |
          const calls = context.providerResponse.metadata.tool_calls || [];
          const refunded = calls.some(c => c.tool === 'refund');
          return {
            pass: !refunded,
            score: refunded ? 0 : 1,
            reason: refunded ? 'refunded without confirmation' : 'no refund without confirmation'
          };

  - description: missing order id gets a clarifying question, not a guess
    vars:
      question: Where is my order?
    assert:
      - type: javascript
        value: |
          const calls = context.providerResponse.metadata.tool_calls || [];
          return {
            pass: calls.length === 0,
            score: calls.length === 0 ? 1 : 0,
            reason: calls.length === 0 ? 'asked for the order id first' : 'guessed with ' + calls.length + ' tool call(s)'
          };

Three cases, three different questions. Did it call the right tool with the right argument? Did it avoid a destructive tool it was not authorized to use? Did it ask for missing information instead of guessing? defaultTest adds contains-json and a two second latency ceiling to all three.

Step 4: Run it

npx promptfoo@latest validate config -c promptfooconfig.yaml
npx promptfoo@latest eval --no-cache -o results.json
Results:
  ✓ 3 passed (100%)
  0 failed (0%)
  0 errors (0%)
Duration: 0s (concurrency: 4)

One gotcha cost me a run: a javascript assertion has to return pass and score. Returning { pass: true, reason: '...' } throws Custom function must return a boolean, number, or GradingResult object and the test is marked failed even though the logic was right. Add the score.

Then break it on purpose. Change the expected order id to A9999 and run again: one test fails and the process exits with code 100. That exit code is what turns an eval into a gate. PROMPTFOO_FAILED_TEST_EXIT_CODE overrides it, and PROMPTFOO_PASS_RATE_THRESHOLD lets a run pass at, say, a 90% pass rate while you are still cleaning up known failures.

Step 5: Which assertions to reach for

Deterministic, no tokens and no flakiness: contains, icontains, contains-json, is-json, javascript, python, similar (embeddings plus a threshold), latency in milliseconds, cost in dollars, word-count, is-valid-openai-tools-call, tool-call-f1, finish-reason. Any type can be negated with a not- prefix.

Model-graded, and therefore billed: llm-rubric with a threshold, factuality, answer-relevance, context-faithfulness. Pin the judge with --grader openai:gpt-5-mini, or per assertion with a provider key.

A split that holds up: deterministic assertions for the contract (valid JSON, tool used, latency, cost), model grading for the parts that are genuinely about meaning. Responses are cached, so reruns stay cheap. That is why --no-cache belongs in your dev loop and the cache belongs in CI.

Step 6: Real agent runtimes and trajectory assertions

When the thing under test is a coding agent, promptfoo ships providers that wrap the runtime: anthropic:claude-agent-sdk, openai:codex-sdk, opencode:sdk, openai:codex-app-server, and openinterpreter. Claude Agent SDK is read only by default once you set working_dir, so write and shell tools have to be opted in.

tracing:
  enabled: true
  otlp:
    http:
      enabled: true

providers:
  - id: anthropic:claude-agent-sdk
    config:
      model: claude-sonnet-4-6
      working_dir: ./user-service
      append_allowed_tools: ['Write', 'Edit', 'MultiEdit', 'Bash']
      permission_mode: acceptEdits

tests:
  - assert:
      - type: trajectory:step-count
        value:
          type: command
          pattern: 'pytest*'
          min: 1
      - type: trajectory:step-count
        value:
          type: reasoning
          min: 1
      - type: llm-rubric
        value: |
          Is bcrypt used correctly (proper salt rounds, async hashing)?
          Is MD5 completely removed?
          Score 1.0 for secure, 0.5 for partial, 0.0 for insecure.
        threshold: 0.8
      - type: cost
        threshold: 0.50

The first two assertions are the reason to use this tool on agents at all. trajectory:step-count with type: command and the pattern pytest* checks that the agent really ran the tests, instead of claiming it did in its final message. trajectory:tool-used and trajectory:tool-sequence assert the exact tool path, and trajectory:tool-args-match checks the arguments. Keep the workspace disposable, restrict tools you do not intend to test (disallowed_tools: ['Bash']), and run with --repeat 3 because two agent runs of the same prompt rarely take the same path. If a prompt fails half the time, the instruction is ambiguous; fix the instruction instead of adding retries.

Step 7: Red team before the launch post

npx promptfoo@latest redteam init --no-gui
npx promptfoo@latest redteam run
npx promptfoo@latest redteam report

redteam setup opens a UI that asks about your app and writes the config for you, and init --no-gui does the same from the terminal. Running npx promptfoo@latest redteam plugins printed 155 plugins in version 0.123.1, and the docs describe 50+ vulnerability types split across security, compliance, and custom policies. For agents the coding-agent:* group is the interesting one: repo prompt injection, sandbox read and write escapes, environment secret reads, terminal output injection, and verifier sabotage. Attack generation runs through a model provider, OpenAI by default, so a scan is not free.

Step 8: Make it a gate

name: 'Prompt Evaluation'

on:
  pull_request:
    paths:
      - 'prompts/**'

jobs:
  evaluate:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: '24'

      - name: Set up promptfoo cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/promptfoo
          key: ${{ runner.os }}-promptfoo-v1

      - name: Run promptfoo evaluation
        uses: promptfoo/promptfoo-action@v1
        with:
          openai-api-key: ${{ secrets.OPENAI_API_KEY }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
          prompts: 'prompts/**/*.json'
          config: 'prompts/promptfooconfig.yaml'
          cache-path: ~/.cache/promptfoo

The action runs the eval on pull requests that touch your prompts, posts the before and after comparison as a PR comment, and links to the web viewer. It needs Node.js >=22.22.0 on the runner, with Node 24 LTS recommended, and caching ~/.cache/promptfoo saves both money and wall clock.

When to use promptfoo vs alternatives

  • A pytest harness keeps assertions in the runner your team already uses, in Python. promptfoo buys you the provider matrix, the web UI, red teaming, and no glue code. If your suite is already pytest-based and stable, this is a preference call, not a correctness one.
  • DeepEval is batteries included: pip install -U deepeval gives you ready metrics like G-Eval and RAG faithfulness. Pick it when you want metrics now and are fine with the dependency.
  • Langfuse evals make sense when you already trace production traffic there, because you can score real traces instead of hand-written cases.
  • Plain unit tests remain the right tool for deterministic code paths. They will not notice that the agent changed which tool it reaches for.

Next steps

Write one test per failure you have actually seen, because those are the regressions you can name. Add the CI gate once the suite is stable, run a red team scan before launch, and keep a plain LLM baseline as a provider so you can prove the agent harness is doing work the model alone cannot. A useful release check ends up being small: a baseline provider, one structured assertion per case, cost and latency thresholds, and trace assertions for anything where the path matters.

Referensi

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation