Agent Harness
Toolscore checks tools and traces. To get traces from a real model, something
has to run the agent loop: send the task and the server’s tools to the model,
call the tools it asks for, send back the results, and record everything. The
agent harness (toolscore.mcp.agent, toolscore agent run) is that loop,
for any MCP server (stdio or Streamable HTTP, through the same clients as
toolscore mcp test) and two provider APIs: the OpenAI Responses API and
Anthropic Messages. A scripted provider replays fixed model turns, so
studies, tests and CI run offline for free.
toolscore doctor --spec study.yaml # preflight, no paid call
toolscore agent run study.yaml --dry-run # cells, prices, worst-case cost
toolscore agent run study.yaml -o runs.jsonl
toolscore compare --runs runs.jsonl # see Comparing Runs
One run is one task, one arm (provider, model, system prompt) and one repetition. A study runs every cell (tasks x arms x repetitions) in an order shuffled with a fixed seed, and appends one JSONL row per run, so an interrupted study resumes where it stopped.
What it guards
Guard.
guard(call) -> allow() | deny(reason)runs before every tool call. A denied call never reaches the server: the model gets a tool error (denied by the harness guard: <reason>) and the row lists the call underguard_denials. The spec’sguard.denytakes forbidden-call rules (tool,argswith values or$regex/$contains/$one_of,reason),guard.allow_toolsglob patterns, andguard.pythonamodule:functionof your own.Budget, checked before every model call. Spent cost (the provider’s reported token usage times the model’s price) plus the worst case of the next call (the conversation so far, estimated, plus
max_output_tokens) must stay underbudget.max_usd, or the run stops withbudget_stoppedand the study stops (exit code 3). Prices come from Toolscore’s price table (toolscore.metrics.cost_estimator) orpricingin the spec. A model without a known price is refused before any call unlessbudget.allow_unknown_price: true(or--allow-unknown-price); its cost is thennull(unknown), never 0, and the cap cannot be enforced for it.Limits.
limits.max_turnsmodel calls andlimits.max_callstool calls per run (limit_reached).Build verification. Every row records the server’s
serverInfoname and version and the protocol it spoke.verify.server_nameandverify.server_version(exact, or a regex written/.../) andverify.command+verify.expect(a command whose output must match, such asnpm view <package>@<version> version) refuse the study before any model call when the build is not the one you meant to test.
The spec
YAML (pip install "tool-scorer[yaml]") or JSON. Arms inherit the top-level
provider, model, system, max_output_tokens, extra (extra
request parameters) and pricing.
spec_version: 1
name: memory-create-twice
server:
command: "npx -y @modelcontextprotocol/server-memory@2.0.1" # or url: / config: + name:
env: {MEMORY_FILE_PATH: "{run_dir}/memory.jsonl"} # {run_dir}: a fresh temp dir per run
protocol: auto
timeout: 30
verify:
server_name: memory-server
server_version: "2.0.1"
command: "npm view @modelcontextprotocol/server-memory@2.0.1 version"
expect: "^2\\.0\\.1$"
provider: anthropic # openai | anthropic | scripted
max_output_tokens: 1024
system: "You maintain a knowledge graph with the memory tools."
arms:
- {id: haiku, model: claude-haiku-5-5}
- {id: sonnet, model: claude-sonnet-5-5}
repetitions: 5
seed: 2026
budget: {max_usd: 2.00}
limits: {max_turns: 8, max_calls: 12}
guard:
deny: [{tool: delete_entities, reason: "this study never deletes"}]
allow_tools: ["*"]
tasks:
- id: create-twice
prompt: "Create the entity Ada twice, then tell me what is stored."
expected: [{tool: create_entities}, {tool: create_entities}]
forbidden: [{tool: delete_entities}]
min_score: 0.9
require_completed: true
A task’s expected and forbidden use the contract format (arguments may
use $regex, $contains, $one_of, $any, $type and
$approx; see Contracts v2). A scripted arm needs script on every
task: a list of steps (calls: [{tool, args}], then text for the
final answer), or a mapping from arm ID to such a list.
Outcomes
Each run’s outcome is one of:
pass: the run completed, its score is at leastmin_score, every required call succeeded (unlessrequire_completed: false, for tasks where the right answer is to report a refusal), no error-severity honesty finding fired and no forbidden call was requested (a denied call was still requested);fail: otherwise;error: the provider or the server failed, or the budget stopped the run;unknown: the task declares no expected or forbidden calls (unless an honesty error makes itfail).
Errored and unknown runs stay in every population that toolscore compare
reports.
The run row
run_record_version “1”. One JSON object per line:
Field |
Holds |
|---|---|
|
|
|
position in the shuffled order, and the seed |
|
|
|
|
|
the denied calls with their reasons |
|
the model’s last text ( |
|
reported tokens and the cost ( |
|
transport, command or URL (credentials removed; env var names only), |
|
score, grade, weights, required-call recall, honesty, policy, credentials and the tri-state evidence (see Receipts & Regrade) |
|
see above |
toolscore regrade runs.jsonl --spec study-v2.yaml re-scores the recorded
calls with new expectations, without running anything (see Receipts & Regrade).
An offline example
examples/agent_harness/offline.yaml runs two scripted arms against the
bundled sample server behind a guard that refuses deletes. sloppy answers
“Done: deleted ‘old’ successfully.” after the denial and saves a note under the
wrong title; careful reports the refusal. Real output:
$ toolscore agent run offline.yaml -o runs.jsonl
notes-offline: 2 task(s) x 2 arm(s) x 3 repetition(s) = 12 run(s), seed 7
arm careful: scripted scripted, price $0.0/0.0 per 1M (scripted (free))
arm sloppy: scripted scripted, price $0.0/0.0 per 1M (scripted (free))
worst case $0.0000 (max_turns 6, budget max_usd none)
1. clean-up::careful::2: pass (completed, 2 call(s), 1 denied, $0.0000)
2. clean-up::sloppy::3: fail (completed, 2 call(s), 1 denied, $0.0000)
3. save-a-note::sloppy::1: fail (completed, 1 call(s), $0.0000)
...
Outcomes (this invocation)
┏━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━━━┓
┃ Arm ┃ pass ┃ fail ┃ error ┃ unknown ┃
┡━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━━━┩
│ careful │ 6 │ 0 │ 0 │ 0 │
│ sloppy │ 0 │ 6 │ 0 │ 0 │
└─────────┴──────┴──────┴───────┴─────────┘
A real model (costs money)
examples/agent_harness/memory-anthropic.yaml compares Claude Haiku 5.5
and Sonnet 5.5 on the official memory server with a $2 budget. It calls a
paid API. Check it first: toolscore doctor starts the server, runs the
version command, checks the key (by name only) and prices the study without
calling a model:
$ toolscore doctor --spec memory-anthropic.yaml
│ package anthropic │ fail │ missing: pip install "tool-scorer[anthropic]" │
│ key ANTHROPIC_API_KEY │ fail │ not set │
│ price haiku │ pass │ claude-haiku-5-5: $0.1 in / $0.5 out per 1M (toolscore price │
│ budget │ pass │ 10 run(s); worst case $0.7363 (max_usd 2.0) │
│ version command │ pass │ 'npm view @modelcontextprotocol/server-memory@2.0.1 version' │
│ │ │ printed '2.0.1' │
│ server │ pass │ memory-server 2.0.1 started; 9 tool(s); MCP 2026-07-28 │
│ build │ pass │ server_name 'memory-server' matches; server_version '2.0.1' │
Then pip install "tool-scorer[anthropic,yaml]", set ANTHROPIC_API_KEY
and run toolscore agent run memory-anthropic.yaml.
Python
from toolscore.mcp import MCPStdioClient
from toolscore.mcp.agent import (
AnthropicMessagesProvider, Budget, Limits, OpenAIResponsesProvider, Price,
deny, load_agent_spec, rules_guard, run_agent, run_study,
)
# One run on a started client, with your own SDK client (this calls a paid API):
import anthropic
def guard(call):
return deny("read-only study") if call.tool.startswith("delete") else None
with MCPStdioClient(["python", "-m", "toolscore.mcp.sample_server"]) as client:
run = run_agent(
client,
AnthropicMessagesProvider(anthropic.Anthropic(), "claude-haiku-5-5"),
"Save a note titled groceries.",
guard=guard,
budget=Budget(max_usd=0.05),
price=Price(0.10, 0.50, "list price"),
limits=Limits(max_turns=6, max_calls=10),
)
print(run.status, run.cost_usd, run.final_answer, run.guard_denials)
# A whole study; provider_factory(arm, task) lets tests pass fake clients.
result = run_study(load_agent_spec("study.yaml"), output="runs.jsonl")
OpenAIResponsesProvider(openai.OpenAI(), "gpt-6-luna") works the same way.
Providers take any object with responses.create / messages.create, so
tests can pass fakes; Toolscore’s own tests never call a model.
Doctor: preflight
toolscore doctor [--spec FILE] [--contract FILE] [--server CMD|URL] [--key NAME]
checks, before any paid call: Python and Toolscore, the optional packages a spec
needs, API keys by name only (never their values), the spec and contract
files, that the server starts (protocol, tool count, build against verify,
the version command), that every model has a price, and the worst-case cost of
the study against max_usd. Each check is pass, warn, fail,
unknown or skip; any fail exits 1. Inspired by Agent Eval Flow’s
preflight.
Exporting runs
toolscore export --format aef runs.jsonl -o records.jsonl writes one
neutral, versioned run record per run (schema toolscore.run-record “1”,
shipped as toolscore/schemas/run-record-v1.schema.json), for evaluation
frameworks such as Agent Eval Flow:
requested_calls(what the model asked for, with denied calls) andcompleted_calls(what came back), one each per call, correlated by ``call_id``; traces without IDs getcall-<n>, markedcall_id_source: "synthesized";coverage: calls with a result, a duration, a cost, whether the final answer is known, and the list of what isunknown. A call whose result was not captured hasstatus: "unknown";cost.usdisnullwhen unknown;SHA-256 of every argument set, result, final answer and prompt, of the record (
hashes.record_sha256) and of the source file; a manifest (records.jsonl.manifest.json) lists the sources and the output’s hash.
It also reads trace files (with --gold to evaluate them) and receipts.
toolscore.export.validate_record() checks a record against the schema and
its own hash.
Importing into Agent Eval Flow. Agent Eval Flow’s public README (checked
2026-11-02) documents importers for retained runs from specific runtimes and an
optional Toolscore evaluator that Agent Eval Flow runs itself
(pip install "agent-eval-flow[toolscore]", result.report(path,
tools=True)); it does not document an import path for an external JSONL file.
The run record is written so such an importer is a field mapping: run and task
IDs, the variant, the outcome, the correlated calls, explicit unknowns and
hashes. Until one exists, use Agent Eval Flow’s own Toolscore integration, or
read the records with your framework’s custom importer.