An agent-agnostic harness that scores how well an LLM agent uses tools to complete multi-step tasks — separating outcome from process, measuring variance, and classifying failures, instead of reporting one pass/fail bit.
Built on the raw Anthropic SDK, no eval frameworks. See CONTEXT.md
for the domain vocabulary and docs/adr/ for the three
architectural decisions this project turns on.
Most agent evals answer a single question: did it succeed? That one bit hides almost everything you need to trust an agent:
- An agent that succeeds by flailing through every tool isn't reliable.
- An agent that fails because a tool handed it garbage isn't broken.
- An agent that passes once might have just gotten lucky.
A single pass/fail score can't tell these apart. ToolEval refuses to collapse them. Every run is scored against four questions, not one:
| Question | What it answers | How it's measured |
|---|---|---|
| Outcome | Did it achieve the goal? | Deterministic state comparison; LLM-as-judge only where state is too rigid |
| Process | Was the path reasonable? | Required calls present, forbidden calls absent, efficiency ratio |
| Variance | Consistent, or just lucky? | N runs per Task; pass rate + outcome/process mean & std |
| Failure cause | When it fails, why? | Mutually-exclusive, first-match failure taxonomy |
These four are reported together as a Result vector. A single blended
number is opt-in (--combine) and explicitly labelled lossy — leading with one
number is the exact thing this project argues against.
The calc Suite and the test suite run fully offline from committed fixtures —
no API key, no network:
pip install -e .
python run_eval.py --suite calc --runs 3 # trivial calc Suite (agnosticism proof)
python run_eval.py --suite job-search --runs 3 # full job-search Suite (the real target)
pytest # fast, offline, deterministicThe scripted job-search Suite is offline too, with one exception: its
graceful-degradation Task is graded by an LLM-as-judge (see §4) that calls
Claude, so without ANTHROPIC_API_KEY that single Task is recorded as a
run_error (flakiness-as-data — the eval still completes and the other four
Tasks score normally). The four Step/Pipeline Tasks need no key in scripted mode.
A Result vector for one run, followed by the variance roll-up across runs:
Result vector — calc.arithmetic (run 0)
┏━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┓
┃ Outcome ┃ Process ┃ Variance ┃ Failure ┃ Do-nothing ┃ Passed ┃
┡━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━┩
│ 1.00 │ 1.00 │ — not computed │ — not computed │ — not │ ✓ │
└─────────┴─────────┴────────────────┴────────────────┴───────────────┴────────┘
Variance — calc.arithmetic (3 runs)
┏━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Runs ┃ Pass rate ┃ Out. mean ┃ Out. std ┃ Proc mean ┃ Proc std ┃ Var flag ┃
┡━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━┩
│ 3 │ 100% │ 1.000 │ 0.000 │ 1.000 │ 0.000 │ — │
└──────┴───────────┴───────────┴───────────┴───────────┴───────────┴───────────┘
Read it left to right: the agent achieved the goal (outcome 1.00) by the
clean path (process 1.00), did so on every run (pass rate 100%, std
0.000), and never tripped a failure category. Variance and failure columns
read — not computed on the single-run card because they're properties of the
set of runs, summarised in the variance table beneath. Each run is also
written to results/ as a standalone, human-readable JSON file
(task + trajectory + score), never overwritten — the filename carries the task
id, run index, and timestamp so history accumulates.
Four ideas carry the design. The harness core contains zero job-search — or any-domain — logic; all domain knowledge lives in a Suite.
The harness reaches every agent through one interface and nothing else:
run(task, tool_handler) -> AgentResultThe harness never re-implements an agent's tool-use loop — doing so would
score a copy of the agent, not the agent, and collapse the whole thesis
(ADR 0001).
Agents register named entry points in a registry; a Task names the entry
point it targets. "Agent-agnostic" means agnostic behind the Contract — any
agent that implements run(...) can be evaluated untouched.
The real Job Application Agent was re-architected from a monolithic tool-use loop into an orchestrator that composes focused Steps (extract → research → analyze → synthesize), each a mini-agent with a restricted tool subset. The full Pipeline is those Steps composed in order.
The payoff is honest multi-granularity evaluation: a single-step Task and the
full-Pipeline Task exercise the same Step code, not a parallel test-only
re-implementation. You can score job_search.extract in isolation and
job_search.pipeline end-to-end, and both numbers describe shipped components.
The injected tool handler mocks only the world boundary — the flaky I/O
tools (fetch_webpage, web_search, read_resume, save_output). The agent's
reasoning always runs live, including any LLM call a Step makes internally.
Swapping the mock handler for a live one (--live) needs no change to agent
logic; the two modes are never mixed within a run.
Crucially, the mocks are not justified by reproducibility (ADR 0002). Cognition runs live, so runs aren't bit-reproducible — the variance machinery exists precisely to quantify that. Mocks earn their place three other ways:
- Experimental control — holding the world fixed makes outcome variance attributable to the agent, not to a changing web. This is what lets the harness answer "consistent, or lucky?"
- Authorable edge cases — adversarial world states (a paywalled posting, no company news, an empty resume, contradictory inputs) can't be summoned reliably from live tools. Fixtures are the only way to build them on demand.
- A demo that doesn't rot — job-posting URLs die within months; frozen,
committed fixtures keep
run_eval.pyworking when a reviewer clones the repo long after the original postings are gone.
Every run flows through plain dataclasses so all scoring is pure data transformation — no agent, no network:
Task— id, description, target entry point, toolset,required_tool_calls,forbidden_tool_calls,minimal_necessary_calls, expected world state, tags.AgentResult— task id, ordered trajectory (every tool call: name, args, result, order), final response, final state (snapshotted before anything overwrites it), run index.TaskScore— outcome, process, opt-in combined, passed flag, failure category.EvalReport— the aggregate of one Eval run (a Suite run N times under one mind + one world): per Task, the run records, variance, and baseline check. Plain data —run_suitebuilds it; the CLI renders and persists it.
Process scoring is generic and explainable: required calls present; forbidden
calls absent only scored where the entry point actually had access to the
tool (otherwise N/A — never reward or penalise a violation that was impossible
by construction); and an efficiency ratio = minimal_necessary_calls / actual_calls, capped at 1.0. No magic-number per-call penalties — a 9-call
solution to a 4-call problem simply scores 0.44.
The harness ships do-nothing and random baselines (baselines.py)
that validate the Task, not the agent: scoring a Task against a no-op answers
"could a no-op pass this?", and a warning fires when it can — a broken Task. The
baseline check runs once per Task on every Eval run (run_baselines in
evaluation.py) and is reported as its own
Baseline line beside the variance stats — a Task-design guardrail, never
blended into an agent score. The failure
taxonomy is a mutually-exclusive, first-match chain — run_error (a run that
raised — live network/API flakiness recorded as data, never an aborted eval),
no_tools (the do-nothing signature), called_forbidden, wrong_tool,
missing_output, wrong_args, excessive_calls, good_process_bad_outcome,
unknown — so a failed run tells you what to actually fix.
| Path | Role |
|---|---|
tooleval/core.py |
Task, TrajectoryStep, AgentResult, TaskScore dataclasses |
tooleval/contract.py |
Agent Contract run(task, tool_handler) -> AgentResult + entry-point registry |
tooleval/tool_handler.py |
Mock tool handler (fixtures) + trajectory-recording wrapper |
tooleval/runner.py |
Invokes an entry point, times it, snapshots final state |
tooleval/scoring.py |
State-based outcome scorer + process scorer + opt-in combine |
tooleval/baselines.py |
Do-nothing + random baselines (warn on trivially-satisfied Tasks); Task-design guardrail, not in the default CLI loop |
tooleval/variance.py |
Per-Task variance stats across runs |
tooleval/taxonomy.py |
Mutually-exclusive failure-category classifier |
tooleval/report.py |
Rich Result-vector report (outcome-first) |
tooleval/persistence.py |
One JSON file per run, never overwritten |
tooleval/suites/stub/ |
Minimal stub Suite (tracer-bullet integration proof) |
tooleval/suites/calc/ |
Trivial calc Suite (agnosticism proof — the guide below) |
tooleval/suites/job_search/ |
Full job-search Suite (the real agent target) |
tests/ |
Seam tests: scorer, handler, persistence, report, end-to-end runner |
python run_eval.py --suite SUITE [--runs N] [--agent MIND] [--combine] [--live]| Flag | Default | Effect |
|---|---|---|
--suite |
(required) | Name of the Suite to run, e.g. calc or job-search. |
--runs |
5 mock / 3 live |
How many times to run each Task — this is how variance is measured. Defaults lower under --live (real tokens, real network); override explicitly anytime. |
--agent |
scripted |
The mind axis. scripted runs the Suite's offline scripted twin; real imports the real agent in-process. Orthogonal to --live (the world axis). Only meaningful for a Suite that has a real backend (job-search). |
--combine |
off | Print a lossy blended score beneath each Result vector. Opt-in only, never the headline. Tune with --outcome-weight / --process-weight (default 0.7 / 0.3, normalised). |
--live |
off | Swap the Suite's mock handler for its real one. Proves the Contract against reality; never mixed with mock in one run. Fails clearly if a Suite has no live handler. |
The job-search Suite can run either its scripted twin (a canned stand-in,
the default offline path) or the real JobPostingAgent (real LLM, real
cognition). That mind axis is orthogonal to the existing world axis (--live):
--agent |
--live |
what runs |
|---|---|---|
scripted (default) |
mock | offline proof — no agent imported, ToolEval standalone (only the judge-graded graceful-degradation Task needs a key) |
real |
mock | Mode 1 — real LLM + mock tools |
real |
live | Mode 2 — real LLM + real tools (true end-to-end) |
scripted |
live | rejected — a canned mind calling real tools is meaningless |
The real agent is a flat, non-packaged sibling repo, imported in-process from a
path given by the JOBPOSTINGAGENT_PATH environment variable (it self-registers
the real Pipeline and Step entry points; see
ADR 0003). Exactly one
backend registers per run, so the scripted twin and the real agent never collide
on the shared job_search.* names. The agent has no dedicated
graceful-degradation entry point, so in real mode that Task is aliased to the
real extract Step (graceful degradation is extract fetching an unreadable
page) — that's how the whole five-Task ladder runs against the real agent.
# Mode 1 — the real agent against canned tool fixtures
JOBPOSTINGAGENT_PATH=/path/to/JobPostingAgent \
python run_eval.py --suite job-search --agent real --runs 1
# Mode 2 — the real agent end-to-end against the real web and disk
JOBPOSTINGAGENT_PATH=/path/to/JobPostingAgent \
python run_eval.py --suite job-search --agent real --liveA missing/blank JOBPOSTINGAGENT_PATH, or a failed import, exits with a clear,
actionable error before any task runs.
Mode 2 runs the whole altitude ladder against the real world, not just
the Pipeline: each of the five Tasks swaps in a live-world variant (same Task
id, so the same per-Task scorer applies) pointed at a real input instead of its
mock fixture. The harness selects these live_tasks only under --live:
| Task | Mode-2 real input | Fragile fixture / override |
|---|---|---|
extract, pipeline |
a real, public job posting | LIVE_JOB_URL_DEFAULT / JOBSEARCH_LIVE_JOB_URL |
research |
a real, well-known company (so web_search returns briefable results) |
LIVE_COMPANY_DEFAULT (Anthropic) / JOBSEARCH_LIVE_COMPANY |
analyze |
the committed résumé on real disk (same text both worlds) | — |
graceful-degradation |
a real access-restricted URL the agent genuinely can't read | RESTRICTED_URL_DEFAULT (httpbin.org/status/403) / JOBSEARCH_RESTRICTED_URL |
The job-posting and restricted URLs are deliberately fragile fixtures —
postings expire, endpoints move — defined and documented in
fixtures.py. When one rots the run
does not crash: a failed fetch becomes a low-scoring run, a hard error
becomes a run_error entry in the failure taxonomy — flakiness (DuckDuckGo
throttling, a 404) is recorded as data, never an aborted eval. Each fragile
fixture takes an env override so a stale default can be replaced for a single
run without editing code:
JOBSEARCH_LIVE_JOB_URL=https://example.com/some-live-posting \
JOBPOSTINGAGENT_PATH=/path/to/JobPostingAgent \
python run_eval.py --suite job-search --agent real --liveRunning the full ladder in Mode 1 already exposes per-altitude differences — in a
verified scripted-vs-real Mode-1 run, research scored process 0.75 and the
Pipeline 0.86, against 1.00 elsewhere — which is the whole point of scoring each
Step in isolation and end-to-end.
A Suite is the only place domain knowledge lives. The harness runner,
scorer, variance stats, taxonomy, and persistence never change — you only add
files under tooleval/suites/<name>/.
This guide is narrated from the real diff between the two shipped Suites.
The calc Suite is the minimal reference — two tools,
two Tasks, ~150 lines. The job_search Suite is
the same shape scaled up to four Steps, a Pipeline, an adversarial Task, and an
LLM judge. Everything job_search adds, it adds inside its own Suite
directory — that's the agnosticism proof.
A Suite is five files:
tooleval/suites/calc/
├── fixtures.py # committed mock world data + build_handler()
├── tasks.py # Task definitions (one per entry point)
├── agent.py # entry points honouring the Agent Contract
├── scorers.py # custom outcome scorers (optional)
└── __init__.py # registers the SuiteSpec
Pair each world-boundary tool call (keyed by its args) with the result it should
return, then hand the list to MockToolHandler.build:
from tooleval.tool_handler import MockToolHandler
EXPRESSION, CALCULATE_RESULT = "6 * 7", 42
FIXTURE_ENTRIES = [
("calculate", {"expression": EXPRESSION}, CALCULATE_RESULT),
]
def build_handler() -> MockToolHandler:
return MockToolHandler.build(FIXTURE_ENTRIES)What
job_searchadds: the same pattern, only bigger — a committed resume, posting HTML, search results, a paywalled posting fixture for the adversarial Task. The file is larger; the shape is identical.
A Task names the entry point it targets and declares everything the scorer needs:
from tooleval.core import Task
ARITHMETIC_TASK = Task(
id="calc.arithmetic",
description=f"Compute the value of the expression '{EXPRESSION}'.",
entry_point="calc.arithmetic",
toolset=["calculate"],
required_tool_calls=["calculate"],
forbidden_tool_calls=["convert"], # scored only because it's in the toolset
minimal_necessary_calls=1, # drives the efficiency ratio
expected_state={"expression": EXPRESSION, "result": CALCULATE_RESULT},
tags=["calc", "arithmetic"],
)What
job_searchadds: one Task per Step plus apipelineTask whoseminimal_necessary_calls=5spans the combined toolset, and agraceful-degradationTask withexpected_state={}— there's no world state for "didn't hallucinate," so it leans on a judge (step 4).
Register a function per entry point. Wrap the injected handler in
RecordingHandler so every call lands in the trajectory, then return an
AgentResult:
from tooleval.contract import ToolHandler, register_entry_point
from tooleval.core import AgentResult, Task
from tooleval.tool_handler import RecordingHandler
@register_entry_point("calc.arithmetic")
def arithmetic_eval(task: Task, tool_handler: ToolHandler) -> AgentResult:
handler = RecordingHandler(tool_handler)
result = handler("calculate", {"expression": EXPRESSION})
return AgentResult(
task_id=task.id,
trajectory=handler.trajectory,
final_response=f"{EXPRESSION} = {result}",
final_state={"expression": EXPRESSION, "result": result},
)What
job_searchadds: its entry points invoke the real, re-architected Step code rather than scripted output, and thepipelineentry point composes the four Steps in order — the same Step code the single-step Tasks run (ADR 0001). The Contract is unchanged.
Most Tasks use the harness's default state-based scorer. Register a custom one
only where state comparison is too rigid — calc does it for floating-point
tolerance:
def score_convert(task: Task, result: AgentResult) -> float:
actual, expected = result.final_state.get("result"), task.expected_state.get("result")
return 1.0 if abs(float(actual) - float(expected)) <= 1e-3 else 0.0What
job_searchadds: a custom scorer for every Task, because a live LLM never reproduces a fixture's final state verbatim, so exact state-equality is meaningless against the real agent. Four are structural/invariant scorers that score the fraction of shape invariants met: one per Step (score_extract/score_research/score_analyze, each scoped to that Step's final state) and one for the whole package (score_pipeline— report saved, required sections present,job_detailspopulated, briefing/analysis non-empty). The fifth, for the graceful-degradation Task, is an LLM-as-judge scorer — the one place state comparison can't express the goal ("absence of hallucination"); it applies a strict rubric and logs the judge's reasoning, so the score is auditable rather than a vibe. No other Task uses a judge.
Bundle tasks, the mock handler, optional per-Task scorers, and an optional live
handler into one SuiteSpec:
from tooleval.suites import SUITE_REGISTRY, SuiteSpec
from tooleval.suites.calc import agent # noqa: F401 — registers entry points
from tooleval.suites.calc.fixtures import build_handler
from tooleval.suites.calc.scorers import score_convert
from tooleval.suites.calc.tasks import ARITHMETIC_TASK, CONVERT_TASK
SUITE_REGISTRY.register(SuiteSpec(
name="calc",
tasks=[ARITHMETIC_TASK, CONVERT_TASK],
mock_handler_factory=build_handler,
live_handler_factory=_build_live_handler, # optional; --live needs it
outcome_scorers={CONVERT_TASK.id: score_convert}, # default scorer otherwise
))Finally, add one import to run_eval.py so the Suite registers
on startup:
import tooleval.suites.calc # noqa: F401python run_eval.py --suite calc --runs 5That's the entire contract. The diff between calc and job_search is the
whole "what scales up" story — more fixtures, real Step code, a Pipeline, an
adversarial Task, one judge — and none of it touches the harness core.