Evaluating agents¶
An eval runs a dataset of cases through your agent, scores each run with metrics, and gives you a report you can gate a build on. Each case is a real run in the engine, so the score can see what the agent actually did: the answer, the tools it called, the steps, tokens and cost it used, and (for delegating agents) what its child runs did.
"""Evals: run a dataset through an agent, score it, gate on the result. Offline (a scripted model stands in)."""
import asyncio
from aaa.algos import ToolLoop
from aaa.eval import Budgeted, Contains, Dataset, EvalSuite, ToolCalled
from aaa.llm import FakeLLMClient, LLMHandler
from aaa.llm.fake import text, tool_call
from aaa.runtime import Engine
from aaa.tools import ToolHandler, ToolRegistry, tool
@tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
def scripted_model(request): # stands in for a real LLM
last = request.messages[-1]
if last.role == "user":
nums = [int(w) for w in (last.content or "").split() if w.isdigit()]
if len(nums) < 2:
return text("I do not know.") # a bad agent on some inputs
return tool_call("add", a=nums[0], b=nums[1])
return text(f"The answer is {last.content}")
DATASET = Dataset.from_list(
[
{"id": "small", "input": "add 2 3", "expected": "5"},
{"id": "large", "input": "add 40 2", "expected": "42"},
{"id": "vague", "input": "add the numbers", "expected": "7"},
]
)
async def main() -> None:
registry = ToolRegistry([add])
algo = ToolLoop(registry)
async with Engine() as engine:
LLMHandler(FakeLLMClient(scripted_model)).attach(engine)
ToolHandler(registry).attach(engine)
engine.register(algo)
suite = EvalSuite(DATASET, algo, [Contains(), ToolCalled("add"), Budgeted(steps=5)])
report = await suite.run(engine)
print(f"pass rate {report.pass_rate:.0%} over {len(report.results)} runs")
for name, s in report.summary().items():
print(f" {name}: mean {s.mean:.2f}")
print("failed:", [r.case.id for r in report.failures])
try:
report.assert_ok(min_pass_rate=0.9) # in CI this fails the build
except AssertionError as e:
print("gate:", str(e).splitlines()[1])
asyncio.run(main())
pass rate 67% over 3 runs
contains: mean 0.67
tool_called: mean 0.67
within_budget.steps: mean 1.00
failed: ['vague']
gate: pass rate 66.7% < 90.0%
Datasets¶
Dataset.from_list, from_jsonl, from_csv. A case is {id, input, expected, tags, metadata}; a string input becomes {"task": ...}.
dataset.where(tag="math") selects a slice. Case ids must be unique.
Metrics¶
| Metric | Checks |
|---|---|
ExactMatch, Contains, Regex, NumericClose, JsonValid(model) |
the answer text |
OutcomeIs("success") |
how the run ended |
ToolCalled("a", forbidden=["b"]), ToolSequence("a", "b") |
the tool-use trajectory |
Budgeted(steps=, tokens=, cost=, latency_s=) |
the run stayed inside an allowance |
SemanticSimilarity(embedder) |
embedding similarity to expected |
LLMJudge(client, rubric) |
a model grades against a rubric |
CallableMetric(fn) |
anything: fn(result) -> bool / float / (value, reason) / Score |
Write your own by implementing name and async score(result) -> Score | list[Score]. Scores are numbers in 0..1 or pass/fail booleans;
Score.cost and Score.usage record what a judge spent. A metric that raises counts as a failed score (never a silent pass), and a case the
harness could not start is an error.
The judge runs outside the evaluated run: its calls are not part of that run's trace or budget, and its cost is reported separately
(report.judge_cost). Use a different, preferably stronger, model than the agent's, and look at a few judged cases by hand before trusting it.
Running¶
EvalSuite(dataset, algo, metrics, concurrency=4, repeats=1, budget=None, tenant=None). repeats=5 runs each case five times, because
agents are not deterministic; the report keeps every run. budget=Budget(max_cost=0.10) bounds each case. Eval runs are stamped
meta={"aaa_eval": True}, so an online evaluator never scores them.
Reports and CI¶
report.save("evals/baseline.json") # once, from a run you accept
report.assert_ok(min_pass_rate=0.9, baseline="evals/baseline.json") # in CI; raises AssertionError listing failing cases
open("eval.xml", "w").write(report.to_junit()) # for CI dashboards
print(report.to_markdown())
assert_ok fails if the pass rate is below the bar or any metric's mean fell by more than tolerance (default 0.02) against the baseline.
Scoring live traffic¶
from aaa.eval import OnlineEvaluator, LLMJudge
async with OnlineEvaluator(engine, [LLMJudge(client, "Is the reply polite and correct?")], sample=0.05, max_cost=5.0, sinks=[langfuse_sink]):
...
It scores a deterministic sample (by trace id, so every process picks the same runs) after the run finishes, in its own consumer group,
so a slow or failing judge never touches the run. max_cost caps judge spend in this process. Each score is recorded in the run's log as an
aaa.eval.score event (so it appears as an evaluator span in later trace exports) and passed to any sink that supports scores.
Note that the sample sees your production inputs and outputs: a hosted judge receives them.
Third-party eval libraries¶
Each library's metrics become AAA metrics, and their judge calls go through your own LLMClient (your gateway, keys and cost
reporting) instead of the library's OpenAI default. Install the library you use; AAA imports it only when you import its adapter.
| Library | Adapter | Install |
|---|---|---|
| autoevals (Braintrust) | aaa.eval.autoevals: AutoevalsMetric(scorer), openai_shim(client) |
pip install "autonomous-agent-algos[autoevals]" (adds openai, which autoevals imports but does not declare) |
| DeepEval | aaa.eval.deepeval: DeepEvalMetric(metric), DeepEvalJudge(client) |
pip install deepeval |
| Ragas | aaa.eval.ragas: RagasMetric(metric), RagasJudge(client) |
pip install ragas "langchain-community<0.4" "langchain-core<1" "langchain-openai<1" |
| Inspect AI | aaa.eval.inspect: aaa_solver(setup) |
pip install inspect-ai |
from autoevals import Factuality, Levenshtein
from aaa.eval.autoevals import AutoevalsMetric, openai_shim
metrics = [AutoevalsMetric(Levenshtein()), AutoevalsMetric(Factuality(client=openai_shim(llm), model="judge-model"))]
from deepeval.metrics import AnswerRelevancyMetric
from aaa.eval.deepeval import DeepEvalMetric, DeepEvalJudge
metrics = [DeepEvalMetric(AnswerRelevancyMetric(model=DeepEvalJudge(llm, model="judge-model")))]
from ragas.metrics.collections import Faithfulness
from aaa.eval.ragas import RagasMetric, RagasJudge
metrics = [RagasMetric(Faithfulness(llm=RagasJudge(llm, model="judge-model")))] # needs case.metadata["retrieval_context"]
- What a case supplies. DeepEval gets an
LLMTestCasewith the input, answer, expected output, the tools the run called (read from the trajectory), token cost and latency, plusmetadata["retrieval_context"],["context"]and["expected_tools"]when present. Ragas gets whatever itsascoreasks for by name:user_input,response,reference,retrieved_contexts; anything else, or a missing field, fails the case with a message instead of a guess. autoevals getsoutput,expectedandinput. - Judge cost. The judge objects count their calls, tokens and cost (
judge.calls,judge.tokens,judge.cost;shim.meterfor autoevals), as running totals, not per case. - Inspect AI keeps its own loop, scorers, sandboxes and logs:
aaa_solver(setup)runs one AAA run per sample (a fresh engine fromsetup, an async context manager yielding(engine, algo)) and returns the answer as the model output, so Inspect'sincludes(),match()or model-graded scorers work on it. Inspect's ownmodelis used only by model-graded scorers. - Verified. Each adapter runs the real library's flow in CI against a fake judge model (DeepEval 4.2, Ragas 0.4.3, autoevals 0.4,
Inspect AI 0.3), including autoevals' two OpenAI code paths (chat completions and the Responses API it picks for
gpt-5*names). Not run: live judge models, DeepEval/Ragas agent and multi-turn metrics, Ragas metrics that need embeddings, Inspect sandboxes. - Dependency warning. Ragas 0.4.3 does not import against the newest LangChain (
langchain_community.chat_models.vertexai, thenContextOverflowError), hence the pins above. DeepEval, Ragas and Inspect AI are large and pin clashing versions of pydantic, protobuf and LangChain: put them in a separate environment if they fight with the rest of your stack.