promptfoo — RAG, Agents & Multi-turn¶
Beyond single prompts: evaluating retrieval-augmented answers, multi-turn conversations, tool calling, agent traces and MCP servers, plus the patterns that keep prompt comparisons and regression sets manageable.
RAG Evals¶
Evaluate retrieval and generation separately so a failure points at one stage.
| Stage | What to check | How |
|---|---|---|
| Retrieval | Right documents come back | Provider that returns the retrieved chunks; contains-all, context-recall |
| Generation | Answer is correct and grounded | Fixed context var; factuality, context-faithfulness, answer-relevance |
| End to end | The deployed pipeline answers correctly | HTTP / custom provider for the app; chunks returned in metadata |
Retrieval only¶
# providers/retrieve.py — the "model" is your retriever
def call_api(query, options, context):
docs = vector_store.query(query) # your code
return {"output": "\n".join(f"{d.name}: {d.text}" for d in docs)}
prompts: ['{{query}}']
providers: [file://providers/retrieve.py] # several retrievers side by side compare vector stores
tests:
- vars: { query: 'What is our reimbursement policy?' }
assert:
- type: contains-all
value: [reimbursement.md, 'Employee Reimbursement Policy']
Generation with fixed context¶
prompts:
- |
Answer using only the documents.
<QUERY>{{query}}</QUERY>
<DOCUMENTS>{{context}}</DOCUMENTS>
providers: [openai:chat:gpt-6-luna]
tests:
- vars:
query: What is the maximum purchase that needs no approval?
context: file://docs/reimbursement.md # or a list of chunks, or a script: file://load_context.py
assert:
- type: contains
value: '$500'
- type: factuality
value: The employee's manager approves larger purchases
- type: context-faithfulness
threshold: 0.9
- type: answer-relevance
threshold: 0.8
A script var is def get_var(var_name, prompt, other_vars): return {"output": retrieve(other_vars["query"])}.
End to end against the app¶
providers:
- id: http
label: rag-api
config:
url: http://localhost:8080/api/rag
method: POST
headers: { Content-Type: application/json }
body: { query: '{{prompt}}' }
transformResponse: | # answer is the output, chunks ride along in metadata
{
output: json.answer,
metadata: { contexts: json.contexts }
}
defaultTest:
assert:
- type: context-faithfulness
contextTransform: context.metadata.contexts
threshold: 0.8
- type: context-relevance
contextTransform: context.metadata.contexts
threshold: 0.3
tests:
- vars: { query: 'How long do refunds take?' }
assert:
- type: context-recall
contextTransform: context.metadata.contexts
value: Refunds are issued within 14 days of purchase.
threshold: 0.8
- type: javascript # deterministic retrieval check
value: context.metadata.contexts.some((c) => c.toLowerCase().includes('refund policy'))
| Assertion | Required vars / fields | Reads as |
|---|---|---|
context-faithfulness |
query, context |
Share of answer claims supported by the context |
context-recall |
value (ground truth), context |
Share of the ground truth found in the context |
context-relevance |
query, context |
Share of the context needed for the query; low is not always bad, tune the threshold per set |
answer-relevance, factuality |
prompt / value |
Answer addresses the question / agrees with the reference |
Context comes from a context var (string or list) or from contextTransform, which returns a non-empty string or list. If the var is named differently, map it in contextTransform. These are model-graded: pin the grader (03). Ragas computes comparable retrieval and generation metrics inside Python test suites; promptfoo is the YAML harness with a UI and CI gate, and both can run over the same golden questions. Security checks for RAG (rag-poisoning, rag-document-exfiltration, rag-source-attribution, indirect-prompt-injection) are red team plugins, see 05.
Multi-turn Conversations¶
[
{"role": "system", "content": "You are a support bot."},
{% for completion in _conversation %}
{"role": "user", "content": {{ completion.input | dump }}},
{"role": "assistant", "content": {{ completion.output | dump }}},
{% endfor %}
{"role": "user", "content": {{ question | dump }}}
]
prompts: [file://chat.json]
tests:
- description: turn 1
vars: { question: 'Hi, my name is Alice.' }
metadata: { conversationId: thread-1 } # tests with the same id share history
options: { storeOutputAs: greeting } # reuse the output as {{greeting}} later
- description: turn 2 remembers the name
vars: { question: 'What is my name?' }
metadata: { conversationId: thread-1 }
assert:
- type: icontains
value: alice
- type: conversation-relevance
threshold: 0.7
- description: new thread starts clean
vars: { question: Say hello }
metadata: { conversationId: thread-2 }
assert:
- type: not-icontains
value: alice
_conversationis an array of{prompt, input, output}from earlier tests in the sameconversationId; prompts that reference it run with concurrency 1.PROMPTFOO_DISABLE_CONVERSATION_VARturns the variable off.- A fixed history is just another var:
messages: [{user: ...}, {assistant: ...}]indefaultTest.vars, dumped into the prompt with{{ messages | dump }}. - For stateful endpoints send a session id per test:
options.transformVars: '{ ...vars, sessionId: context.uuid }'and put{{sessionId}}in a header or body;sessionParserreads a server-issued id from the response. promptfoo:simulated-userplays the user for several turns (config.instructions,maxTurns, optionalinitialMessages) against the provider under test; set it asdefaultTest.provideror per test.- Multi-turn red team strategies (
crescendo,goat,jailbreak:hydra) need a stateful target, see 05.
Tool Calling and Structured Output¶
providers:
- id: openai:chat:gpt-6-luna
label: agent
config:
reasoning_effort: none # function calling on Chat needs this for this model family
tools:
- type: function
function:
name: get_weather
description: Get the current weather for a city
parameters:
type: object
properties: { city: { type: string } }
required: [city]
additionalProperties: false
tool_choice: auto # or {type: function, function: {name: get_weather}} to force one
- id: openai:chat:gpt-6-luna
label: triage
config:
response_format:
type: json_schema
json_schema:
name: ticket_triage
strict: true
schema:
type: object
properties:
priority: { type: string, enum: [P1, P2, P3, P4] }
category: { type: string }
required: [priority, category]
additionalProperties: false
tests:
- providers: [agent]
vars: { question: 'What is the weather in Paris?' }
assert:
- type: is-valid-openai-tools-call
- type: tool-call-f1
value: [get_weather]
- type: javascript
value: |
const calls = Array.isArray(output) ? output : output.tool_calls;
const args = JSON.parse(calls[0].function.arguments);
return calls.length === 1 && calls[0].function.name === 'get_weather' && args.city === 'Paris';
- type: finish-reason
value: tool_calls
- providers: [triage]
vars: { question: 'Triage this ticket: I was charged twice' }
assert:
- type: javascript # schema output arrives parsed
value: output.priority === 'P1' && output.category === 'billing'
| Provider | Tool shape | Structured output |
|---|---|---|
| OpenAI Chat | {type: function, function: {name, parameters}} |
response_format (json_schema) |
| OpenAI Responses | {type: function, name, parameters} |
response_format (sent as text.format) |
| Anthropic | {name, input_schema} |
output_format: {type: json_schema, schema} |
| Ollama | OpenAI shape | format with a JSON schema (use think: false on reasoning models) |
Define tools once in the Chat shape and share them with a YAML anchor; promptfoo converts them for Anthropic, Bedrock and Google. tools: file://tools.yaml (or file://tools.py:get_tools) loads definitions from a file, response_format: file://schema.json loads a schema, and tests[].options.response_format switches the schema per test. Provider functionToolCallbacks call local functions and return their results as the output; there is no automatic model-to-tool loop, so full agent loops need a custom provider or an agent provider.
Agent Traces and Trajectories¶
Trace assertions check what an agent did, not only what it said. Promptfoo runs an OTLP receiver (default port 4318) and passes a W3C traceparent to the provider; spans emitted under it are attached to the test.
tracing:
enabled: true
otlp:
http:
enabled: true # port 4318
providers:
- file://agent_provider.js # uses context.traceparent to export spans
tests:
- vars: { question: 'Where is order 123?' }
assert:
- type: trajectory:tool-used
value: search_orders
- type: trajectory:tool-sequence
value: { mode: exact, steps: [search_orders, compose_reply] } # default mode: in_order
- type: trajectory:tool-args-match
value: { name: search_orders, args: { order_id: '123' } } # mode: partial (default) or exact
- type: trajectory:step-count
value: { type: tool, max: 3 }
- type: trace-span-count
value: { pattern: 'tool.*', min: 2 }
- type: trace-span-duration
value: { pattern: 'tool.*', max: 2000 }
// agent_provider.js (excerpt): report each tool call as a span below promptfoo's trace
// crypto = require('node:crypto'); t0, t1 = BigInt millisecond timestamps of the tool call
const [, traceId, parentSpanId] = (context.traceparent || '').split('-');
await fetch('http://127.0.0.1:4318/v1/traces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans: [{
traceId, parentSpanId, spanId: crypto.randomBytes(8).toString('hex'), name: 'tool.search_orders', kind: 1,
startTimeUnixNano: String(t0 * 1_000_000n), endTimeUnixNano: String(t1 * 1_000_000n),
attributes: [{ key: 'tool.name', value: { stringValue: 'search_orders' } }],
}] }] }] }),
});
Tool names are read from attributes such as tool.name, function.name and gen_ai.tool.name. With the OpenTelemetry SDK use propagation.extract on traceparent and a normal OTLP exporter. trace-error-spans checks error rates, trajectory:goal-success asks a judge whether the traced run reached a goal, and javascript / python assertions get context.trace.spans. Built-in providers (OpenAI, Anthropic, Azure, Bedrock, Vertex, Ollama, HTTP and others) add their own spans.
MCP¶
providers:
- id: mcp # test the MCP server itself
config:
enabled: true
server:
command: node
args: ['mcp_server.js'] # or url: https://host/mcp with auth and headers
name: orders
# server.tools / server.exclude_tools filter the tool list; timeout in ms; transformResponse reshapes the result
prompts: ['{{prompt}}']
tests:
- vars:
prompt: '{"tool": "get_order_status", "args": {"order_id": "ORD-1"}}'
assert:
- type: contains
value: shipped
The prompt is a JSON {tool, args} call and the output is the raw MCP content blocks unless transformResponse reshapes them. To give MCP tools to a model under test, set mcp.enabled: true on openai:chat / anthropic:messages providers; executed calls appear in metadata.toolCalls for routing assertions. MCP-specific attacks use the mcp red team plugin.
Comparing Prompt Versions¶
prompts:
- id: file://prompts/v1.txt
label: v1-brief
- id: file://prompts/v2.txt
label: v2-polite
tests:
- vars: { question: 'How long do refunds take?' }
assert:
- type: contains
value: 14 days # absolute check for every version
- type: select-best # relative check across versions in this row
value: choose the most concise correct answer
Each prompt gets a column; compare pass rate and named metrics per column, or open two evals in the UI compare view. select-best fails the losers by design, so keep it out of gating suites. Keep one prompt file per version in git, label them, and use --filter-prompts v2 to run a single one.
Regression Sets and Scenarios¶
- Golden set: keep real failures as rows in
tests/regression.csv(question,__expected1,__metadata:source). Add a row for every production bug; run it on each PR with deterministic assertions only. - Slices:
metadataplus--filter-metadata area=billingruns part of the set;--filter-first-nand--filter-sample 20 --filter-sample-seed 1give cheap smoke runs. - Failures first:
promptfoo eval --filter-failing results.json(or an eval id) re-runs only failing rows after a fix;--retry-errorsrepeats provider errors only. - Flakiness:
--repeat 5 --no-cacheshows how often a test passes; gate on pass rate rather than a single sample. - Growth:
promptfoo generate datasetdrafts extra inputs andpromptfoo generate assertionsdrafts checks from the prompts.
scenarios cross a set of data with a set of tests:
scenarios:
- description: Support questions per locale
config: # each entry is a vars set
- vars: { locale: en }
- vars: { locale: de }
tests: # every test runs for every vars set
- description: capital question
vars: { question: 'What is the capital of France?' }
assert:
- type: icontains
value: paris
See also¶
- promptfoo — LLM Evals & Red Teaming from YAML
- promptfoo — Test Cases & Assertions
- promptfoo — Red Teaming, Testing & CI
- Ragas — Evaluation for LLM, RAG and Agent Applications
- DeepEval — LLM Testing Guide
- Langfuse — Datasets & Evaluations
- Arize Phoenix — LLM Tracing & Evaluation