Catch a right reply reached the wrong way
Four refund runs send the customer the same, correct-sounding reply, and every text check passes. Their traces tell a different story: one run skipped identity verification, one refunded $499.90 instead of $49.99, and one looped over the same lookup. Trace-aware checks catch all three.
| For | Developers of tool-using agents: refunds, bookings, account changes |
| Difficulty | Intermediate |
| Time | ~10 minutes |
| Capabilities | Tool expectations, Trajectory scorers |
| Status | Stable |
The problem
An agent's reply is what it says it did. The tools it called are what it actually did. Evals that only read the reply can't see a skipped verification step or a wrong amount sent to the payment API. Here the reply for the wrong-amount run still says $49.99. Checking that the right tools were called isn't enough either: the order of the calls and their arguments matter.
Architecture
All four runs reply with the same text and pass contains 8/8. correct passes. skipped-verification never calls verify_identity. wrong-amount calls issue_refund with 499.9. looping repeats lookup_order and calls web_search. 1 of 4 runs passes.
Static diagram
flowchart LR
R[Recorded run<br/>reply + EvalTrace] --> E[evaluate_suite]
E --> T[Text: expected_contains]
E --> O[Trace: required_tool_order]
E --> A[Trace: expected_tool_arguments]
E --> P[Scorers: tool_call_precision,<br/>no_redundant_tool_calls]
Each span in the EvalTrace names a tool and carries its arguments in
attributes["tool_args"]. Build the trace from whatever your framework
records.
Prerequisites
- Python 3.10 or newer.
-
agentic-evals 0.9.0 (
required_tool_orderandexpected_tool_argumentsshipped in 0.7.0):python -m venv .venv && source .venv/bin/activate python -m pip install agentic-evals==0.9.0 -
No model, API key or network. The script is
examples/usecases/refund_trajectory.py.
Walkthrough
1. Record each run as a trace
Each run is a list of tool calls. In production these come from your agent's instrumentation (AgenticLens, OpenTelemetry or your own logs).
2. State how a refund must be done
One TestCase per run, with the same expectations:
- the reply mentions the amount and the order (
expected_contains) lookup_order,verify_identityandissue_refundare all called, in that order (required_tools,required_tool_order)issue_refundis called with exactlyorder_id="ORD-7731"andamount=49.99(expected_tool_arguments)- no tool outside those three, and no repeated call
(
tool_call_precisionandno_redundant_tool_calls, both at threshold 1.0)
3. Run it
Save this as refund_trajectory.py:
"""Four refund runs send the same reply. Only the trace shows which one was done right.
Each run is a recorded trace: the tools the agent called, with their
arguments. Nothing here calls an agent or a model.
Run: python refund_trajectory.py
"""
from typing import Any
from agentic_evals import (
EvalSpan,
EvalTrace,
EvaluationReport,
EvaluationSample,
EvaluatorConfig,
TestCase,
TestSuite,
default_registry,
evaluate_suite,
)
REPLY = "Your refund of $49.99 for order ORD-7731 has been issued."
TOOLS = ["lookup_order", "verify_identity", "issue_refund"]
def call(tool: str, **args: Any) -> EvalSpan:
return EvalSpan(tool_name=tool, attributes={"tool_args": args})
RUNS = {
"correct": [
call("lookup_order", order_id="ORD-7731"),
call("verify_identity", customer_id="C-118"),
call("issue_refund", order_id="ORD-7731", amount=49.99),
],
"skipped-verification": [
call("lookup_order", order_id="ORD-7731"),
call("issue_refund", order_id="ORD-7731", amount=49.99),
],
"wrong-amount": [
call("lookup_order", order_id="ORD-7731"),
call("verify_identity", customer_id="C-118"),
call("issue_refund", order_id="ORD-7731", amount=499.9),
],
"looping": [
call("lookup_order", order_id="ORD-7731"),
call("lookup_order", order_id="ORD-7731"),
call("lookup_order", order_id="ORD-7731"),
call("verify_identity", customer_id="C-118"),
call("web_search", query="refund policy"),
call("issue_refund", order_id="ORD-7731", amount=49.99),
],
}
def refund_case(case_id: str) -> TestCase:
return TestCase(
id=case_id,
name=case_id.replace("-", " "),
input="I'd like a refund for order ORD-7731.",
expected_contains=["$49.99", "ORD-7731"],
required_tools=TOOLS,
required_tool_order=TOOLS,
expected_tool_arguments={"issue_refund": {"order_id": "ORD-7731", "amount": 49.99}},
evaluators=[
EvaluatorConfig(name="tool_call_precision", threshold=1.0),
EvaluatorConfig(name="no_redundant_tool_calls", threshold=1.0),
],
)
def build_report() -> EvaluationReport:
suite = TestSuite(
name="refund-trajectory",
version="1",
cases=[refund_case(run_id) for run_id in RUNS],
)
samples = [
EvaluationSample(
case_id=run_id, output=REPLY, trace=EvalTrace(trace_id=run_id, spans=spans)
)
for run_id, spans in RUNS.items()
]
return evaluate_suite(suite, samples, registry=default_registry())
def main() -> None:
report = build_report()
for case in report.cases:
print(f"{case.case_id:<22} {'PASS' if case.passed else 'FAIL'}")
for score in case.scores:
if not score.passed:
print(f" {score.name}: {score.explanation}")
print()
for metric, stats in report.summary.metrics.items():
print(f"{metric:<24} {stats.passed}/{stats.total} passed")
print(f"\n{report.summary.passed_cases}/{report.summary.total_cases} runs passed")
if __name__ == "__main__":
main()
python refund_trajectory.py
Expected output
correct PASS
skipped-verification FAIL
required_tool:verify_identity: Required tool 'verify_identity' was not called.
tool_order: Required order ['lookup_order', 'verify_identity', 'issue_refund'] not met: ['verify_identity'] never called.
wrong-amount FAIL
tool_arg_values:issue_refund: Tool 'issue_refund' was called with [{'order_id': 'ORD-7731', 'amount': 499.9}], not {'order_id': 'ORD-7731', 'amount': 49.99}.
looping FAIL
tool_call_precision: 5/6 tool calls were in the allowed set ['issue_refund', 'lookup_order', 'verify_identity'].
no_redundant_tool_calls: 2/6 tool calls exactly duplicated an earlier call.
contains 8/8 passed
no_redundant_tool_calls 3/4 passed
required_tool 11/12 passed
tool_arg_values 3/4 passed
tool_call_precision 3/4 passed
tool_order 3/4 passed
1/4 runs passed
contains passed 8 of 8: by text alone, all four runs look correct. Each of
the other metrics caught a different failure.
How it works
required_tool_orderpasses when each listed tool is first called before the next one. Other calls in between are allowed, so the looping run still meets the order. A listed tool that is never called fails it.expected_tool_argumentspasses when at least one call to the tool has every listed argument with an equal value. Values are compared as given:5is not"5", and499.9is not49.99.tool_call_precisionis the share of calls to allowed tools. It falls back torequired_toolswhen noallowed_toolsare configured.no_redundant_tool_callscounts calls that exactly repeat an earlier tool and arguments.- Metrics roll up by check.
required_tool:verify_identityandrequired_tool:issue_refundboth count underrequired_toolinsummary.metrics, which is why it shows 11/12.
Variations
- Forbid dangerous tools outright.
forbidden_tools=["delete_account"]fails any run that calls them. Pair it with a gate floor such asmin_metric_pass_rate={"forbidden_tool": 1.0}. - Require arguments without fixing their values.
required_tool_arguments={"issue_refund": ["order_id", "amount"]}checks the keys are present. - Cap the number of turns.
max_turns=4fails a run whosetrace.metadata["turn_count"]is above 4, or missing. - Use AgenticLens traces.
agenticlens.evaluation.to_eval_trace(run)builds theEvalTracefrom a recorded AgenticLens run.
Next steps
- Block a release when one safety case fails: turn these checks into a CI release gate.
- Don't let a flaky LLM judge approve a release
- Core concepts