Skip to main content

Evaluation

When to use this

Use the evaluation framework when you need to:

  • Score agent accuracy on a labelled dataset (golden answers).
  • Assert that the agent follows the correct tool-call sequence (trajectory matching).
  • Run LLM-as-judge scoring against a rubric.
  • Run goal-driven user simulation against the agent.

The evaluation runner is separate from unit tests. It is designed to run as a CI step or a recurring offline report.

This page covers the data model and the runner. Two companion pages go deeper:

Import paths

from agentflow.qa.evaluation import (
AgentEvaluator,
EvalConfig,
CriteriaConfig,
CriterionConfig,
MatchType,
Rubric,
EvalSet,
EvalSetBuilder,
EvalCase,
Invocation,
MessageContent,
SessionInput,
ToolCall,
TrajectoryStep,
StepType,
EvalReport,
EvalCaseResult,
CriterionResult,
TrajectoryCollector,
make_trajectory_callback,
)

Everything above is also re-exported from agentflow.qa.


Wiring a run

Evaluation needs two objects: a compiled graph and the TrajectoryCollector whose callback manager that graph was compiled with. Without that pairing the collector sees no events and every trajectory criterion scores zero.

The short way:

from agentflow.qa.evaluation import AgentEvaluator, create_eval_app

app, collector = create_eval_app(build_my_graph()) # uncompiled StateGraph in
evaluator = AgentEvaluator(app, collector)

The explicit way, when you need to control compilation yourself:

from agentflow.qa.evaluation import TrajectoryCollector, make_trajectory_callback

collector = TrajectoryCollector(capture_all_events=True)
_, callback_mgr = make_trajectory_callback(collector, config={"thread_id": "eval-1"})

app = my_state_graph.compile(callback_manager=callback_mgr)

make_trajectory_callback() returns a tuple (collector, callback_manager) — pass the second element to compile().


EvalCase

A single labelled test case. Fields:

FieldTypeDefaultDescription
eval_idstrrandom UUIDUnique ID for this case.
namestr""Label shown in reports.
descriptionstr""Longer description.
conversationlist[Invocation][]One entry per turn. Expected tools, node order and responses live here.
session_inputSessionInputdefault instanceapp_name, user_id ("test_user"), state, config. config is merged into the run config.
tagslist[str][]Used by EvalSet.filter_by_tags().
metadatadict[str, Any]{}Free-form. HallucinationCriterion reads metadata["context"]; FactualAccuracyCriterion reads metadata["reference_facts"].

There is no expected_response or expected_tools attribute on EvalCase itself — those are constructor arguments of the factory methods, stored on the Invocation objects inside conversation.

EvalCase.single_turn

from agentflow.qa.evaluation import EvalCase, ToolCall

case = EvalCase.single_turn(
eval_id="weather-paris-001",
user_query="What is the weather in Paris today?",
expected_response="It is sunny in Paris.",
expected_tools=[ToolCall(name="get_weather", args={"location": "Paris"})],
expected_node_order=["MAIN", "TOOL", "MAIN"],
name="Paris weather",
)
ParameterTypeDefaultDescription
eval_idstrrequiredUnique ID for this case.
user_querystrrequiredInput message from the user.
expected_responsestr | NoneNoneExpected final response. None means response criteria skip the case.
expected_toolslist[ToolCall] | NoneNoneExpected tool calls. ToolCall objects, not plain strings.
expected_node_orderlist[str] | NoneNoneExpected node visit sequence.
namestr""Label shown in reports.
descriptionstr""Longer description.

EvalCase.multi_turn

case = EvalCase.multi_turn(
eval_id="multi-turn-001",
conversation=[
("Hello", "Hi"),
("What can you do?", "I can answer questions"),
("Tell me about Python", "Python is a programming language"),
],
expected_tools=[ToolCall(name="search_docs")],
)
ParameterTypeDefaultDescription
eval_idstrrequiredUnique ID for this case.
conversationlist[tuple[str, str]]required(user_query, expected_response) per turn.
expected_toolslist[ToolCall] | NoneNoneAttached to the first invocation only.
namestr""Label shown in reports.
descriptionstr""Longer description.

The evaluator feeds the agent one user message per turn and accumulates tool calls, node visits and messages across all turns. Criteria then score the accumulated execution once, not per turn.

Invocation

One turn inside EvalCase.conversation.

FieldTypeDefaultDescription
invocation_idstrrandom UUIDTurn identifier.
user_contentMessageContentrequiredThe user message for this turn.
expected_tool_trajectorylist[ToolCall][]Tools expected during this turn.
expected_node_orderlist[str][]Node sequence expected during this turn.
expected_intermediate_responseslist[MessageContent][]Reserved for intermediate output.
expected_final_responseMessageContent | NoneNoneExpected reply for this turn.

Invocation.simple(user_query, expected_response=None, expected_tools=None, expected_node_order=None) builds one without touching MessageContent directly.


ToolCall

Represents an expected or actual tool invocation.

from agentflow.qa.evaluation import ToolCall

tc = ToolCall(
name="get_weather",
args={"location": "Paris", "units": "celsius"},
call_id=None, # optional
result=None, # populated on actual calls
)

ToolCall.matches

tc.matches(other, check_args=True, check_call_id=False)  # -> bool

Compares against another ToolCall. Names must always be equal. Arguments are only compared when check_args=True and the expected call defines non-empty args — an expected ToolCall with args={} accepts any arguments. Trajectory criteria call this with check_call_id=False.


TrajectoryStep

A recorded step in the execution trajectory.

FieldTypeDefault
step_typeStepTyperequired
namestrrequired
argsdict[str, Any]{}
timestampfloat | NoneNone
metadatadict[str, Any]{}
from agentflow.qa.evaluation import TrajectoryStep

node_step = TrajectoryStep.node("RESEARCH_NODE")
tool_step = TrajectoryStep.tool("search", args={"query": "AI trends"})

Both factories take the step name as a plain string and accept extra keyword arguments, which are stored in metadata.

StepType

ValueDescription
StepType.NODEA graph node was entered.
StepType.TOOLA tool was called.
StepType.MESSAGEA message step.
StepType.CONDITIONALA conditional-edge step.

EvalSet

A collection of EvalCase objects.

FieldTypeDefault
eval_set_idstrrandom UUID
namestr""
descriptionstr""
eval_caseslist[EvalCase][]
metadatadict[str, Any]{}
from agentflow.qa.evaluation import EvalSet

eval_set = EvalSet(eval_set_id="capitals", name="Capitals", eval_cases=[case1, case2])
MethodDescription
EvalSet.from_file(path)Classmethod. Load from a JSON file matching the EvalSet schema.
to_file(path) / save(path)Write to a JSON file.
add_case(case)Append a case.
get_case(eval_id)Look up a case, or None.
filter_by_tags(tags)Cases carrying all of the given tags.
len(eval_set) / iterationCase count / iterate over cases.

The on-disk format is a single JSON object, not JSONL. EvalSetBuilder offers a fluent way to construct one — see Building eval sets.


EvalConfig

Holds the criteria to apply during evaluation, plus run-level settings.

FieldTypeDefaultDescription
criteriaCriteriaConfigempty CriteriaConfig()Which criteria run.
user_simulator_configUserSimulatorConfig | NoneNoneSimulator settings.
parallelboolFalseRun cases concurrently.
max_concurrencyint4Concurrency cap.
timeoutfloat300.0Seconds per case.
verboseboolFalseVerbose logging.
mock_modeboolFalseSkip actual execution.
reporterReporterConfigdefault instanceAutomatic report generation.

criteria is a typed model, not a free-form dict. CriteriaConfig forbids unknown fields, so each criterion goes in its own named slot:

from agentflow.qa.evaluation import (
CriteriaConfig,
CriterionConfig,
EvalConfig,
MatchType,
)

config = EvalConfig(
criteria=CriteriaConfig(
tool_name_match=CriterionConfig.tool_name_match(threshold=1.0),
trajectory=CriterionConfig.trajectory(match_type=MatchType.IN_ORDER),
llm_judge=CriterionConfig.llm_judge(),
),
parallel=True,
max_concurrency=4,
)

CriteriaConfig fields

Every field is CriterionConfig | None, defaulting to None (criterion off).

FieldCriterion classReported as
tool_name_matchToolNameMatchCriteriontool_name_match_score
trajectoryTrajectoryMatchCriteriontool_trajectory_avg_score
node_orderNodeOrderMatchCriterionnode_order_score
response_matchResponseMatchCriterionresponse_match_score
rouge_matchRougeMatchCriterionrouge_match
contains_keywordsContainsKeywordsCriterioncontains_keywords
llm_judgeLLMJudgeCriterionfinal_response_match_v2
rubric_basedRubricBasedCriterionrubric_based_final_response_quality_v1
factual_accuracyFactualAccuracyCriterionfactual_accuracy_v1
hallucinationHallucinationCriterionhallucinations_v1
safetySafetyCriterionsafety_v1
simulation_goalsSimulationGoalsCriterionsimulation_goals

The "reported as" column is the criterion's name — the key you will see in EvalSummary.criterion_stats and in CriterionResult.criterion.

Built-in configurations

ClassmethodCriteria enabled
EvalConfig.default()trajectory (EXACT, threshold 1.0) + response_match (LLM, threshold 0.8)
EvalConfig.strict()trajectory (EXACT, check_args=True, 1.0) + response_match (0.9) + llm_judge (0.9, 5 samples)
EvalConfig.relaxed()trajectory (IN_ORDER, check_args=False, 0.8) + response_match (0.6)

Methods

MethodDescription
EvalConfig.from_file(path)Classmethod. Load config from JSON.
to_file(path)Write config to JSON.
get_criterion_config(name)Fetch a CriterionConfig by CriteriaConfig field name.
enable_criterion(name, config=None)Enable by field name. Raises ValueError for unknown names.
disable_criterion(name)Set enabled=False on that criterion.
with_rubrics(rubrics)Return a deep copy with rubric_based configured.

CriterionConfig fields and factory methods are documented in Criteria; the criterion classes themselves in Evaluation criteria.


MatchType

ValueDescription
MatchType.EXACTSame items, same positions.
MatchType.IN_ORDERExpected items must appear in order; extras allowed between them.
MatchType.ANY_ORDERAll expected items must appear, in any order.

Rubric

from agentflow.qa.evaluation import Rubric

rubric = Rubric(
rubric_id="accuracy",
content="The response must contain the correct capital city.",
weight=1.0,
)
FieldTypeDefaultDescription
rubric_idstrrequiredUnique ID; shown in details["rubrics"].
contentstrrequiredGrading criterion text passed to the judge.
weightfloat1.0Relative importance. Weights do not have to sum to 1.

TrajectoryCollector

Records node visits, tool calls and per-node LLM output during a graph run. It is a BasePublisher, fed by PublisherCallback.

from agentflow.qa.evaluation import TrajectoryCollector, make_trajectory_callback

collector = TrajectoryCollector(capture_all_events=True)
_, callback_mgr = make_trajectory_callback(collector)

app = graph.compile(callback_manager=callback_mgr)
await app.ainvoke({"messages": [...]})

for step in collector.trajectory:
print(step.step_type, step.name)
ParameterTypeDefaultDescription
capture_all_eventsboolFalseAlso retain every raw EventModel on collector.events.
AttributeTypeDescription
trajectorylist[TrajectoryStep]Ordered node and tool steps.
tool_callslist[ToolCall]Tool calls with args and results.
node_visitslist[str]Node names in visit order.
node_responseslist[NodeResponse]Per-node input/output snapshots.
final_responsestrText from the last non-tool-call node invocation.
eventslist[EventModel]Raw events, when capture_all_events=True.
start_time / end_timefloat | NoneFirst and last event timestamps.

AgentEvaluator calls collector.reset() before every case.

make_trajectory_callback

def make_trajectory_callback(
collector: TrajectoryCollector,
config: dict | None = None,
) -> tuple[TrajectoryCollector, CallbackManager]

Builds a CallbackManager with a PublisherCallback registered for the TOOL, MCP and AI invocation types. config may carry thread_id and run_id, which are stamped onto every emitted event. Pass the returned callback manager to graph.compile().


AgentEvaluator

Main evaluation runner.

from agentflow.qa.evaluation import AgentEvaluator, EvalConfig

evaluator = AgentEvaluator(app, collector, config=EvalConfig.default())
report = await evaluator.evaluate(eval_set)
print(report.format_summary())

Constructor parameters

ParameterTypeDefaultDescription
graphCompiledGraphrequiredGraph compiled with the collector's callback manager.
collectorTrajectoryCollectorrequiredCollector wired into that graph.
configEvalConfig | NoneNoneFalls back to EvalConfig.default().

Concurrency is not a constructor argument — pass parallel and max_concurrency to evaluate(), or set them on EvalConfig.

evaluate

async def evaluate(
eval_set: EvalSet | str,
parallel: bool = False,
max_concurrency: int = 4,
verbose: bool = False,
output_dir: str | None = None,
) -> EvalReport

eval_set accepts an EvalSet or a path to an eval set JSON file (a missing file raises FileNotFoundError). After building the report, evaluate() runs the configured reporters unless config.reporter.enabled is False; output_dir overrides ReporterConfig.output_dir for that run. Parallel mode is only used when parallel=True and the set has more than one case.

Other methods

MethodSignatureDescription
evaluate_caseasync (case: EvalCase) -> EvalCaseResultRun one case. Useful for per-case pytest assertions.
evaluate_syncclassmethod (graph, collector, eval_set, config=None, verbose=False) -> EvalReportBlocking wrapper around evaluate() via asyncio.run.
evaluate_fileclassmethod async (agent_module, eval_file, config_file=None) -> EvalReportImport a module exposing graph / compiled_graph / agent_graph / app and evaluate it.

Result types

EvalReport

Returned by evaluate().

AttributeTypeDescription
eval_set_idstrID of the evaluated set.
eval_set_namestrHuman-readable name.
resultslist[EvalCaseResult]Individual case results.
summaryEvalSummaryAggregate statistics.
config_useddict[str, Any]Snapshot of the config for provenance.
timestampfloatWhen the run finished.
metadatadict[str, Any]Free-form.
passedproperty boolTrue when summary.pass_rate == 1.0.
failed_casesproperty list[EvalCaseResult]Cases where passed is False, errors included.
passed_casesproperty list[EvalCaseResult]Cases that passed.
MethodDescription
get_case_result(eval_id)Fetch one case result, or None.
format_summary()Human-readable multi-line summary string.
to_file(path) / EvalReport.from_file(path)JSON round-trip.
EvalReport.create(eval_set_id, results, eval_set_name="", config_used=None)Build a report and compute its summary.

The report has no overall_score or pass_rate attribute of its own — the rate lives on report.summary.pass_rate. Full EvalSummary fields are documented in Evaluation harness.

EvalCaseResult

AttributeTypeDescription
eval_idstrThe case ID.
namestrThe case name.
passedboolTrue when every criterion passed.
criterion_resultslist[CriterionResult]One entry per criterion, in evaluation order.
actual_trajectorylist[TrajectoryStep]Recorded trajectory.
actual_tool_callslist[ToolCall]Recorded tool calls.
actual_responsestrThe agent's final response.
messageslist[dict[str, Any]]Flattened message history.
node_responses / node_visitslist[...]Per-node snapshots and visit order.
duration_secondsfloatTime for this case.
errorstr | NoneSet when the run itself failed.
turn_resultslist[dict[str, Any]]Per-turn data for multi-turn cases.
token_usage / agent_token_usageTokenUsageTotal (agent + judges) and agent-only.
node_detailslist[NodeDetail]Per-node LLM input/output and tokens.
MemberDescription
get_criterion_result(name)Fetch one CriterionResult by criterion name.
failed_criteria / passed_criteriaProperties filtering criterion_results.
is_errorProperty; True when error is set.

criterion_results is a list, not a dict. Use get_criterion_result(name) for lookups by name.

CriterionResult

AttributeTypeDescription
criterionstrThe criterion's name.
scorefloatScore 0.0–1.0.
passedboolscore >= threshold.
thresholdfloatThreshold used.
detailsdict[str, Any]Criterion-specific detail.
errorstr | NoneSet when the criterion itself failed.
token_usageTokenUsageJudge tokens for this criterion.
reasonproperty str | NoneShortcut for details.get("reason").
is_errorproperty boolTrue when error is set.

Reporters

Reporters run automatically after evaluate() unless disabled. To drive them yourself, use ReporterManager — see Evaluation harness. The individual reporters are also usable directly:

from agentflow.qa.evaluation import (
ConsoleReporter,
HTMLReporter,
JSONReporter,
JUnitXMLReporter,
print_report,
)

print_report(report, verbose=False, use_color=True) # console shortcut
ConsoleReporter(verbose=True).report(report) # console, full control

JSONReporter(indent=2).save(report, "eval-report.json")
HTMLReporter().save(report, "eval-report.html")
JUnitXMLReporter().save(report, "eval-report_junit.xml")

Every reporter implements BaseReporter.generate(report, output_dir=None). The file reporters additionally offer save(report, path) and a string form (JSONReporter.to_json, HTMLReporter.to_html, JUnitXMLReporter.to_xml).


DEFAULT_JUDGE_MODEL

from agentflow.qa.evaluation.config.types import DEFAULT_JUDGE_MODEL
# "gemini-2.5-flash"

The default LLM for every judge-based criterion. Override per criterion with judge_model= on the relevant CriterionConfig factory method.


Full end-to-end example

import asyncio

from agentflow.qa.evaluation import (
AgentEvaluator,
CriteriaConfig,
CriterionConfig,
EvalCase,
EvalConfig,
EvalSet,
MatchType,
ToolCall,
create_eval_app,
)

from my_project.graph import build_graph # returns an uncompiled StateGraph


async def main():
# 1. Compile with a collector attached
app, collector = create_eval_app(build_graph())

# 2. Build the eval set
eval_set = EvalSet(
eval_set_id="capitals",
name="Capital cities",
eval_cases=[
EvalCase.single_turn(
eval_id="capitals-001",
user_query="What is the capital of France?",
expected_response="The capital of France is Paris.",
expected_tools=[ToolCall(name="lookup_capital")],
),
EvalCase.single_turn(
eval_id="capitals-002",
user_query="What is the capital of Germany?",
expected_response="The capital of Germany is Berlin.",
expected_tools=[ToolCall(name="lookup_capital")],
),
],
)

# 3. Configure criteria
config = EvalConfig(
criteria=CriteriaConfig(
trajectory=CriterionConfig.trajectory(
threshold=1.0,
match_type=MatchType.IN_ORDER,
),
response_match=CriterionConfig.response_match(threshold=0.8),
),
)

# 4. Run
evaluator = AgentEvaluator(app, collector, config=config)
report = await evaluator.evaluate(eval_set, verbose=True)

# 5. Inspect
print(report.format_summary())
print(f"Pass rate: {report.summary.pass_rate:.0%}")
for case in report.failed_cases:
for cr in case.failed_criteria:
print(f"{case.eval_id} · {cr.criterion}: {cr.score:.2f} < {cr.threshold}")


asyncio.run(main())

Common errors

ErrorCauseFix
ValidationError: Extra inputs are not permitted on EvalConfigcriteria was given a free-form dict. CriteriaConfig has fixed field names and forbids extras.Use CriteriaConfig(trajectory=..., response_match=...).
AttributeError on the object returned by make_trajectory_callback()It returns (collector, manager), not a manager._, mgr = make_trajectory_callback(collector).
Trajectory and node-order criteria always score 0.0The graph was compiled without the collector's callback manager.Use create_eval_app(), or pass the manager to compile().
FileNotFoundError: Eval set file not foundPath passed to evaluate() does not exist.Check the path relative to the working directory.
AttributeError: 'EvalReport' object has no attribute 'overall_score'No such attribute.Use report.summary.pass_rate and summary.criterion_stats.
AttributeError: 'EvalCaseResult' object has no attribute 'case_id'The field is eval_id.Use result.eval_id.
Criterion score is 0.5 with reasoning "No LLM provider available"The judge model could not be resolved to a configured provider.Install the provider extra and set its API key.
MatchType.EXACT failures when the run looks correctThe actual trajectory has extra tool calls.Switch to MatchType.IN_ORDER.
report.summary.error_cases equals the case countThe graph raises on every case.Run one evaluate_case() and read result.error.