A benchmark that places non-stationarity in the harness — tools, skills, and specialist agents — rather than in the task stream. 17 streams, 802 tasks, two evaluation modes: deployment and self-evolving adaptation.
Modern LLM-based agents operate through a harness of tools, reusable skills, and specialist agents. In practice this harness continually evolves as new capabilities are added. Existing continual-learning benchmarks typically place non-stationarity in the task stream while keeping the harness fixed. EvoHarnessBench inverts that: the externally supplied harness itself expands, stage by stage.
The benchmark contains 17 multi-stage streams constructed deterministically from verifier-based benchmarks (EnterpriseOps-Gym and Agentic Last Exam), comprising 802 tasks instantiated as 1,510 axis-specific evaluation examples. Two complementary settings isolate the central challenges: deployment evaluation (retention as the harness expands, with no experience carried forward) and self-evolving adaptation evaluation (whether accumulated persistent state remains useful as new capabilities arrive).
Current agents do not yet keep pace when the harness grows.
Frontier agents can lose competence on previously solved tasks simply as the externally supplied harness expands — harness-induced forgetting. Model parameters are unchanged; only the exposed capability set grows. The largest case is −34.7% BWT on the agents axis (ALE, deployment).
Improvements vary substantially across harness stages, capability axes, and environments. MemToolAgent lifts tools on EOG to 38.6%, while on ALE most methods stay close to or below the deployment baseline.
Methods that best preserve previously accessible competence often impair adaptation to newly introduced capabilities, and vice versa. Positive BWT with negative FWT is a recurring pattern.
Negative deployment BWT appears even when neither model parameters nor persistent state are updated. The mechanism differs across axes: tools enlarge the executable action space, skills must be retrieved and engaged, and agents add delegation and coordination demands. Retention must be evaluated as the harness itself evolves.
Task-relevant exposure helps for tools and skills, whereas agent adaptation benefits more from a stable broader pool. Inner adaptation cannot be considered independently of outer harness evolution.
FWT and BWT reveal behavior hidden by aggregate performance. A system can improve overall while becoming worse either at exploiting newly introduced capabilities or at preserving earlier competence, so the two should be evaluated separately along the harness trajectory.
These results motivate bi-level adaptation: inner updates from current experience, plus an outer objective that checks whether those updates still hold once the harness moves again — including detecting when persistent artifacts have gone stale.
@article{evoharnessbench2026,
title={EvoHarnessBench: Can Your Agents Keep Pace with an Evolving Harness?},
author={Ke, Zixuan and Patil, Vaidehi and Shi, Haizhou and Li, Yang
and Liu, Ye and Shekkizhar, Sarath and Koul, Anurag
and Wang, Jiayu and Nguyen, Xuan Phi and Yavuz, Semih
and Bansal, Mohit and Joty, Shafiq},
year={2026}
}
The benchmark separates outer harness evolution (tools, skills, and agents accumulating across stages) from optional inner self-evolving adaptation (persistent state updated inside a stage). Then: how streams are built, and how expansion looks on each axis.
Capabilities are released from core to long tail. Earlier ones stay as affordances or distractors. This study isolates monotonic growth — nothing is retired.
Sits outside every stage: the inner loop may write it, and it outlives the harness version that produced it. Deployment evaluation leaves it empty; self-evolving evaluation carries it forward.
Does harness expansion itself degrade previously solved tasks?
Can self-evolving adaptation keep pace as capabilities arrive?
Do retention and adaptation pull in different directions?
How do the three axes — tools, skills, agents — differ?
A harness stream is a nested sequence H1 ⊆ H2 ⊆ ⋯ ⊆ HT. Each task is feasible at its assigned stage and requires at least one newly introduced capability. At test time the system receives the full cumulative harness — earlier capabilities remain as affordances or distractors.
No persistent state across stages. A fresh instance is evaluated on the cumulative eval set under Ht. Isolates the direct effect of harness expansion: can the agent exploit new capabilities while withstanding distraction from the growing pool?
Persistent state zt (memory, learned skills, prompts, or code) may be updated from an adaptation split and carried forward. Measures whether accumulated experience supports new capabilities — or goes stale and misdirects execution.
We report pass rate and cost, plus transfer metrics that separate new-task adaptation from retention:
17 streams from two seed benchmarks, constructed deterministically from EnterpriseOps-Gym and Agentic Last Exam. No LLM-generated tasks. Capabilities are released from core to long tail.
Every task the benchmark evaluates on, as the agent receives it: a single request against a live environment, feasible at the stage that introduced its capabilities and graded only on the state it leaves behind. Open one to read its prompt, the harness it was offered, and the checks it is scored against — then open any tool, skill, specialist or piece of software it names to read what that is. The same request can appear under more than one axis, since what the agent is handed differs even when the request does not.
1,061 evaluation tasks read from the benchmark corpus — the test split of every
domain the paper reports, across all harness stages. Prompts, oracle harness, staged files and
verifiers are reproduced verbatim; nothing here is recomputed. Figures are equally literal:
an EnterpriseOps figure is a screenshot of that task's own environment, seeded from the exact
SQL the harness feeds the gym and captured before the agent touches anything, and an ALE
figure is the artifact staged in the task directory — the drawing, the slide, the clip
— scaled for the web and otherwise untouched. 839 of the 1,061 tasks carry one.
A note on the EnterpriseOps pictures. The 503 tasks draw on 51 distinct seeded databases, so tasks in one domain generally show different environments — but those 51 render as 28 distinct pictures, because each app view shows a curated slice of its gym and a number of seeds differ only in timestamps, in rows below the fold, or in tables the view never displays. Where two environments are pixel-identical they share one file. Only this app view is shown; the relational view that explains how a task is graded is a different question and is not reproduced here.
Pass rates are verifier success on the held-out evaluation split under the cumulative harness. Switch axes to see deployment vs. self-evolving methods, and EOG vs. ALE.
| Tools | Skills | Agents | |
|---|---|---|---|
| Deployment expansion | Accuracy ↑, cost ↑ | Little effect | Environment-dependent |
| Largest harness-induced forgetting | −5.3% | −4.0% | −34.7% |
| Best adaptation gain | +27.8% MemToolAgent | +27.5% GEPA | +110.2% Meta-Harness |
| Best adaptation context | Task-specific | Task-specific | Final full pool from the beginning |
| Observed bottleneck | Extracting useful tool experience | Engaging the right skills | Balancing delegation coverage and selectivity |
Adaptation gains are relative to the cumulative deployment baseline on EOG. Forgetting is worst-case deployment BWT.
Broader catalogs can raise pass rate (26.0% → 30.2% on EOG) while inflating tokens (27.2M → 75.8M). MemToolAgent is the strongest adapter (38.6%), but ALE gains do not follow.
Pool expansion is nearly free at deployment (Codex stays at 18.9% on EOG). The hard part is engagement: default GPT-5 systems invoke almost none of the offered skills; GEPA is the clearest adapter (24.1%).
The hardest axis. Task-specific Codex reaches only 6.5% on EOG and cumulative deployment 8.8%. Meta-Harness lifts that to 18.5% (+110.2% relative), while ALE shows the most severe forgetting in the paper (−34.7% BWT).
Broader tool exposure improves accuracy but raises cost. The larger catalog is not only a distractor — agents can use extra affordances, at the price of more search.
MemToolAgent 38.6%, ReasoningBank 36.9%, Meta-Harness 35.2% vs. 30.2% deployment on EOG. On ALE most methods stay at or below the baseline.
Deployment BWT is negative on both environments. On ALE, GEPA and Meta-Harness improve retention but FWT drops to −28.5% and −11.1% — inner adaptation can over-specialize.
Task-specific tools during MemToolAgent adaptation cut time 23.9h → 17.9h with similar pass rate. Structured memories beat raw replay; schemas and overly shared memories can hurt.
Grey = task-specific reference. Green = best overall method. ▣ = multi-agent. Values are pass %.
Broader skill exposure has little aggregate effect. Codex stays at 18.9% on EOG; tokens barely move. Skills must be retrieved rather than remaining as always-on actions.
GEPA is the clear gain on EOG (18.9% → 24.1%). Memory methods are modest or mixed; Codex native memory slightly drops pass rate.
Mild deployment forgetting. GEPA reaches +14.9% BWT on ALE with −4.0% FWT — preserving old procedures does not imply learning new ones.
Default GPT-5 invokes almost no offered skills; task-specific GPT-5.5 invokes 82%, Claude Code 15%, task-specific GEPA raises GPT-5 to 31%. Skill learning needs grounded refinement, coverage, and control over sequential accumulation.
Environment-dependent. Codex improves on EOG (6.5% → 8.8%) and degrades on ALE (5.3% → 4.2%). Coordination is hard even with a task-specific pool.
Largest relative gains of any axis on EOG: Meta-Harness 18.5% from 8.8%. ALE does not follow — only GEPA clearly improves (6.3%).
EOG is comparatively favorable (GEPA +14.3% FWT). ALE deployment Codex hits −34.7% BWT, the strongest forgetting in the paper.
Adaptation helps required-agent recall (79% → 89%), not precision (~90%). Forgetting is lost coverage of previously successful specialists, not distractor takeover. Agent adaptation prefers a stable full pool over a changing sequence.
“—” = not completed within a comparable compute budget.
Aggregate pass rates say that an expanding harness hurts. A case says how. Every case in the benchmark is here — a single task at a single (adapt stage, test stage) pair, with every system’s attempt beside it: the prompt as the model received it, what the graders checked, what the harness offered, and what each system actually reached for. The list opens on the cases where the systems disagree most, since a task they all solve or all fail shows nothing; clear that filter to browse the rest.
Loading the case table…
3,395 cases and 17,992 system attempts, read from the benchmark’s per-task record — one row per (task, system, adapt stage, test stage) cell. Nothing on this page is recomputed from the trials.
You can score your own agent on these same executable tasks in two environments. The
hosted service runs today: it holds the environment and the grader, so there is nothing
to stand up and you supply only an f(task) callable. Running locally is
where the corpus itself lives — the task data is already public on Hugging Face, and the
runner that executes and grades it against your own machine is on the way.
run_benchmark does all four, once per task in the scope, so the only step you
write is the second. A leaderboard row covers both benchmarks below, and they hand your agent
different affordances.
The agent acts by calling MCP tools bound to a fresh per-session database, and hidden
SQL verifiers grade the state it leaves behind. You get task.mcp_servers
and task.mcp_session(s). Eight published streams — calendar, CSM, drive,
email, HR, hybrid, ITSM, teams — plus enterprise_tri_hybrid, a cross-gym
stress domain no published table counts.
The task stages input files; the agent works however it likes and submits a
deliverable, scored by the task’s own evaluate(). You get
task.inputs(), task.fetch_input(p) and
task.submit_text(p, s). ALE is a flat layout, so pass domain=None,
and its tasks are deliberately hard — a low score is the norm.
The hosted service is authenticated, so the first step happens in a browser rather than in code. Log in to the demo, click MyAuthtoken, and copy the service API key it shows you. The same key covers both halves of a run: the scoring calls, and the MCP tool calls your agent makes while it acts.
Sign in at the demo and open MyAuthtoken from the signed-in view. That page issues the key; it is yours, so keep it out of shared notebooks and out of version control.
Two different keys are in play, and confusing them is the most common setup failure.
EVAL_SERVICE_API_KEY opens this service and comes from MyAuthtoken.
OPENAI_API_KEY is whatever your method thinks with, and it comes from
your own account: the service hosts the environment and the grader, but it does not pay for
your inference. Export them once and nothing downstream has to mention either again —
EvalClient() reads the first and sends it as a bearer token.
export EVAL_SERVICE_API_KEY="<paste the key from MyAuthtoken>" # this service
export OPENAI_API_KEY="sk-..." # what YOUR method thinks with
Prompting works too, and is the easier route in a notebook where you would rather not leave either key in a cell that might get committed.
import getpass, os
os.environ["EVAL_SERVICE_API_KEY"] = getpass.getpass("Eval service key (MyAuthtoken): ")
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API key: ")
Your OpenAI key is only ever seen by the agent you hand it to. The one deliberate exception
is the two provided harnesses, "react" and "codex" — they
execute on the service, so the key travels with that request, and they require it.
Omit it and the SDK raises MissingAPIKey before anything is sent, rather than
letting a forgotten key score a clean-looking 0.0 across a whole sweep.
The client SDK is served by the service, so there is no PyPI account, no repo checkout,
and no service URL anywhere in your code afterwards. EvalClient() resolves its
endpoint on its own: the base_url argument, then $EVAL_SERVICE_URL, then
the URL baked into the served wheel.
WHEEL=$(curl -s "$EVAL_SERVICE_URL/sdk" | python -c 'import json,sys; print(json.load(sys.stdin)["path"])')
pip install "$EVAL_SERVICE_URL$WHEEL"
Install the wheel the deployment is currently serving, not merely a wheel: a
stale pre-installed copy shadows it and then drifts from the API silently. The notebooks check
simple_agentic_evals.__version__ against GET /sdk and reinstall when
the two disagree.
from simple_agentic_evals import (EvalClient, ServiceError, run_benchmark,
react_agent, acp_codex_agent, to_openai_tools)
client = EvalClient() # endpoint and auth resolved for you -- no URL to paste
LLM_MODEL = "gpt-4o-mini" # whatever YOUR method thinks with
# Every /v1/* route needs the key, health included, so this one call both connects
# and proves the key -- a bad one fails HERE rather than three sections later.
print("connected ->", client.health())
The service is a deployment, not a library. The environments, the graders and the two provided harnesses all execute on that host, so what actually works depends on what it has installed — and the failure mode is quiet. An unavailable harness can return without acting, and grading then reports the do-nothing floor: a plausible-looking bad score rather than an error. Every notebook therefore opens with a check, one row per capability, before it trusts a number.
h = client.health()
print(h["ok"], h["active_sessions"], "live session(s), ttl", h["ttl_sec"], "s")
# The ALE denominator. Only the tasks a published number can count are served, so this
# is what an ALE score is divided by -- if the manifest disagrees with the catalog, the
# score is not comparable to anyone else's.
m = h["ale_tasks"]
print(f"ale {m['n_runnable']}/{m['n_suite']} runnable, agrees={m['manifest_agrees']}")
# The rows that matter most: the harnesses that run ON the service. `stopped == "error"`
# means the harness failed -- treat that as no result, never as a zero.
t = next(client.tasks("evovling_tools", "eog", 1, "test", "hr", limit=1))
with t:
run = react_agent(t, api_key=os.environ["OPENAI_API_KEY"],
model=LLM_MODEL, max_steps=1)
print("react ->", run.stopped, run.n_calls, "tool calls")
The ALE row is about a subtler kind of wrong. ALE ships 152 tasks, but many need a non-Linux
VM and a few die in their own loader on any given host — and a task that provisions and
returns 0.0 for an environmental reason is indistinguishable from an agent that
tried and failed. The service serves only what its exclusion manifest calls measurable, and
the check re-derives that count from the catalog and fails if the two have drifted.
run_benchmark takes anything callable that accepts a task. There is no
base class to subclass and nothing to register, so if your method already exists you are
writing an adapter, not a port. You never write with task: either — a
fresh environment is provisioned before each call and torn down after it.
The grader never reads your return value; it reads the environment you acted on. What you leave in the gym database or in the sandbox filesystem is your answer.
mcp.close() before returning, from a finally: — a crash
mid-task should still release the session.
Your model calls never touch the service, so it cannot count them.
return {"total_tokens": n} fills the cost column; omit it and the row reads
not measured rather than a misleading 0.
def solve_eog(task, max_steps=6):
"""EOG: act on the gym through MCP tools. Replace the loop with your method."""
used = 0
mcp = task.mcp_session(task.mcp_servers[0])
try:
tools = to_openai_tools(mcp.list_tools()) # MCP schemas -> OpenAI specs
msgs = [{"role": "system", "content": task.system_prompt or ""},
{"role": "user", "content": task.user_prompt or ""}]
for _ in range(max_steps):
r = oai.chat.completions.create(model=LLM_MODEL, messages=msgs, tools=tools)
used += r.usage.total_tokens
m = r.choices[0].message
msgs.append(m.model_dump(exclude_none=True))
if not m.tool_calls: # no call -> the agent is done
break
for tc in m.tool_calls:
out = mcp.call_tool(tc.function.name,
json.loads(tc.function.arguments or "{}"))
msgs.append({"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(out)[:4000]})
finally:
mcp.close() # rule 2 -- even if we raised
return {"total_tokens": used}
def solve_ale(task):
"""ALE: read the staged inputs, write the deliverable. One shot, no tools."""
files = task.inputs()
head = task.fetch_input(files[0]["path"])[:4000] if files else ""
r = oai.chat.completions.create(model=LLM_MODEL, messages=[
{"role": "system", "content": task.system_prompt or ""},
{"role": "user", "content": f"{task.user_prompt}\n\n{head}"}])
task.submit_text(task.output_path, r.choices[0].message.content or "")
return {"total_tokens": r.usage.total_tokens}
def my_agent(task):
"""One entry point, both benchmarks. This is the function you swap out."""
return solve_eog(task) if task.mcp_servers else solve_ale(task)
One entry point covers both benchmarks by branching on task.mcp_servers, which
is empty on ALE. Swap the bodies for LangGraph, your own planner, a memory scheme or another
provider — the grade neither knows nor cares.
Whatever you already have — a prompt optimizer, a memory, a multi-agent scaffold, a container, a CLI — connects through one wrapper and one of two ports. Your policy runs in your process and calls the service, so its language, framework and runtime stay yours; anything reachable from inside a Python function can be scored.
Port A — tools. task.mcp_url(server) with a bearer header is a
standard MCP endpoint over streamable HTTP, so any conformant client connects to it,
the official MCP SDK included. Underneath, the contract every agent framework consumes is
just a list of JSON-Schema tool declarations plus a call(name, args)
function — which is why a policy we have never seen still plugs in.
server = task.mcp_servers[0]
url = task.mcp_url(server) # hand these two to your own MCP client
headers = {"Authorization": f"Bearer {EVAL_SERVICE_API_KEY}", **server.headers}
# Or use the bundled minimal client. These two calls are the entire seam --
# everything else in the SDK is a convenience on top of them.
mcp = task.mcp_session(server)
tools = mcp.list_tools() # 1. WHAT YOUR POLICY CAN DO (plain JSON Schema)
mcp.call_tool(tools[0]["name"], {}) # 2. HOW YOUR POLICY ACTS (a plain function)
mcp.close()
Port B — artifacts. fetch_inputs_to(dir) stages the task’s files
on your disk and submit_dir(dir) ships back whatever your policy wrote. The
contract is a filesystem, so a container, a CLI, a remote job or another language all sit
behind it equally well. Submitted paths are normalized under the task’s
output/, and non-UTF-8 files are base64-encoded automatically so binaries survive.
import subprocess, tempfile
from pathlib import Path
def containerized_agent(task):
"""An existing container, unchanged, scored on an ALE task.
It never talks to the service: it reads one directory and writes another,
exactly as it does on your laptop. We fill the first, we collect the second.
"""
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
indir = work / "input" # we fill this one
outdir = work / "output" # your system fills this one
outdir.mkdir()
task.fetch_inputs_to(indir) # 1. service -> your disk
subprocess.run([ # 2. your system runs, however it likes
"docker", "run", "--rm", "-v", f"{work}:/work", "my-system:latest",
"--input", "/work/input", "--output", "/work/output",
], check=True, timeout=1800)
task.submit_dir(outdir) # 3. your disk -> service, then grading
# Returning 0 would be worse than saying nothing: it reports a measurement of zero.
return None
# Nothing to do with Docker, really. For a plain CLI, steps 1 and 3 are identical and
# step 2 becomes: subprocess.run(["my-system", "--in", indir, "--out", outdir])
That is the whole evaluation. run_benchmark walks the scope, calls your agent
once per task, grades what it left behind, and hands back a report.
.success_rate is Pass % — strict, every verifier on the task
passed — and .accuracy is Score, the mean verifier pass rate. The
distance between them is partial credit, and reporting only one of them hides which kind of
method you built.
report = run_benchmark(my_agent, mode="deployment_eval", client=client,
benchmark="eog", limit=2, progress=True)
print(report) # the paper-style block
print(report.success_rate, report.accuracy) # Pass % (strict), Score (partial credit)
print(report.agent_s, report.total_tokens) # agent time, not wall clock; None if unmeasured
print(report.n_errors, report.complete) # a non-zero n_errors did not measure what you think
def floor_agent(task):
"""Acts on nothing. Whatever this scores, the environment was giving away."""
return {"total_tokens": 0}
floor = run_benchmark(floor_agent, "deployment_eval", client=client,
benchmark="eog", limit=2, progress=False)
print(f"floor {floor.accuracy:.3f} mine {report.accuracy:.3f}")
# ^ if these two match, your agent is not acting -- check that it reaches mcp.call_tool
limit= is for rehearsal only. It proves the adapter runs and the grader sees what
you left behind, and the report stamps the run SUBSAMPLED so a debug number can
never be mistaken for a result. Drop it for anything comparable.
A leaderboard row is both benchmarks, repeated so it carries a spread. Three details make the
numbers comparable rather than merely plausible. Hours are summed agent duration, not wall
clock — provisioning and grading are ours, so they do not count against you, which
is why the row reads agent_s and not latency_s. ± is the
population standard deviation over repeated runs, not a standard error and not a
confidence interval; the published rows use three. And Overall pools tasks, not
percentages: total solved over total attempted, so EOG’s 454 tasks outweigh
ALE’s 63, and a macro-average of the two rates would be a different, larger number.
# `leaderboard_row` and `show` are ~40 lines defined in the Level 1 notebook, not in the
# SDK. They run both benchmarks `seeds` times and return exactly the record the
# leaderboard stores. A row built with `limit` is stamped `partial` -- do not submit it.
row = leaderboard_row(my_agent, "My Method", cat="memory", seeds=3) # hours, real money
show(row)
# Underneath it is nothing but repeats of the call above, without the `limit`:
#
# run_benchmark(my_agent, "deployment_eval", client=client, benchmark=bench)
#
# Pass % mean(r.success_rate) strict -- every verifier on the task passed
# Score mean(r.accuracy) partial credit -- mean verifier pass rate
# h mean(r.agent_s) / 3600 agent time; r.latency_s is wall clock, not this
# Tok M mean(r.total_tokens) / 1e6 None rather than 0 when nothing reported usage
# Overall sum(n_success) / sum(n_tasks) pooled across both, over tasks not percentages
# +/- pstdev over the `seeds` repeats
# Two switches worth knowing before you commit:
# dataset= the evolving axis -- "evovling_tools" | "evovling_skills" | "evovling_agents",
# each a separate leaderboard.
# mode= the question. "deployment_eval" is a fixed method under a growing harness and
# is the right default; a method that LEARNS between stages belongs in
# "self_evolving_adapt_eval" -- the next tab.
version was never an arbitrary shard. Every domain arrives in stages, and
each stage adds tools, SKILL.md bundles or roster specialists and brings
its own cohort of tasks. Two populations move at once, and crossing them gives the object the
whole evolving study is built on: a lower-triangular performance matrix, cohort
τ evaluated under harness stage t, defined only for τ ≤ t
because a cohort does not exist until its stage is reached. One flag fills it.
report = run_benchmark(my_agent, "deployment_eval", client=client,
domain="calendar", matrix=True) # costs roughly T x the last row
# cohort v1 cohort v2 cohort v3
# under H_v1 0.31 . . <- adaptation: v1 tasks, v1 tools
# under H_v2 0.28 0.40 . <- retention: v1 tasks, v2 tools exist
# under H_v3 0.25 0.36 0.22 <- the last row is what ACC pools
#
# Down a column is one cohort's story as the world grows around it. 0.31 -> 0.28 -> 0.25 is
# FORGETTING: nothing about those tasks changed, only the harness did. That column delta is
# what BWT summarizes.
print(report.bwt, report.fwt) # cohort-size-weighted means, exactly as the appendix defines
print(report.per_stage_acc) # the diagonal, by stage
deployment_eval isolates the harness’s effect by holding the method fixed.
self_evolving_adapt_eval asks the other question — whether learning keeps
up with growth — by handing your method each stage’s training split before
that stage is evaluated. BWT is meaningful in both; FWT only exists in self-evolving,
because it measures adaptation, and omitting adapt there is an error rather than
a silent fallback.
class EvolvingAgent:
"""A policy with state: `adapt` writes it, `__call__` reads it.
run_benchmark picks up `.adapt` automatically, so this object is the whole
integration -- no registration, no adapt= argument needed.
"""
def __init__(self):
self.notes = []
def adapt(self, stage, tasks): # once per stage, with that stage's TRAIN split
for t in tasks:
with t: # prompts exist only once a task is provisioned
self.notes.append(f"[v{stage}] {(t.user_prompt or '')[:140]}")
def __call__(self, task):
# `act` is the loop from "Wrap your method", with room in the system prompt
# for whatever the policy has learned so far.
return act(task, notes="\n".join(self.notes[-8:]))
evolve = run_benchmark(EvolvingAgent(), "self_evolving_adapt_eval", client=client,
domain="calendar", matrix=True)
print(evolve.bwt, evolve.fwt) # FWT is None in deployment mode; here it is real
All three notebooks talk to the same live deployment and carry the current service endpoint, so none of them needs anything stood up. They share a Setup cell and a health check, and then each answers one question — pick by how far you intend to go.
f(task), connect it through either port, smoke-test it, check it against the
do-nothing floor, then produce the eight columns the leaderboard stores.
8 code cells · …_quick_start.ipynb
2
Continual learning
What that single number cannot say. The lower-triangular performance matrix behind it:
adaptation on the diagonal, retention below it, BWT and FWT weighted as the appendix
defines them, and the adapt(stage, tasks) hook for methods that learn.
9 code cells · …_quick_start_detail.ipynb
3
Supported harnesses & modes
The one you come back to. Standard non-evolving evaluation, the react and
codex harnesses with their real costs and failure modes, all three modes, ALE
in depth, persistent memory, raw HTTP, and the complete API dictionary.
10 code cells · …_full.ipynb
The corpus is public today. Three datasets, one per axis of harness evolution, Apache-2.0,
one row per task: the prompts the agent receives, the staged tool lists that
resource_mode selects between, and the verifier definitions the task is graded
against. That is enough to read the benchmark, filter it, count it, and build against its
layout without asking anyone for access.
ZixuanKe/evovling_tools
4,615 rows · 49 configs
Evolving skills
ZixuanKe/evovling_skills
3,243 rows · 31 configs
Evolving agents
ZixuanKe/evovling_agents
3,327 rows · 32 configs
All three sit in the EvoHarnessBench collection on Hugging Face.
The selector the hosted client takes is not a different vocabulary from the dataset layout.
A dataset is the axis, a config is {domain}_v{stage}, and the
split is train or test — so anything you slice
locally names the same thing you would pass to client.tasks(...).
from datasets import load_dataset
# One axis of harness evolution; one stage of one domain; the evaluation split.
ds = load_dataset("ZixuanKe/evovling_tools", "hr_v1", split="test")
row = ds[0]
row["user_prompt"] # the request, exactly as the agent receives it
row["oracle_tools"] # the annotated required set -> resource_mode="oracle"
row["cummulative_tools"] # everything up to this stage -> resource_mode="accumulative"
row["verifiers"] # JSON: the checks this task is scored against
row["gym_servers_config"] # JSON: which gym to seed, and from which snapshot
Those columns are the EOG shape. The ale_* configs carry
task_prompt, input_files, agent_must_do and the task
taxonomy instead, because an ALE task is graded on a submitted artifact rather than on
database state.
What the rows reference but do not ship is the environment itself: the seeded gym
databases behind gym_servers_config, the staged blobs behind
input_files, and the code that turns a verifier definition into a pass or a
fail. Until that is released, a local grade is not something you can produce
— reading and analysing the corpus is. To score an agent today, use the hosted
service.
Every reported system, one axis of harness evolution at a time. Systems run the same tasks under the same accumulated harness, so the columns are comparable down an axis — but not across them, since each axis has its own task set. Each row states its backbone model and the harness it ran in, because the field is not uniform: the controlled systems hold GPT-5 fixed, SkillOpt runs GPT-5.5, and Claude Code is Sonnet-4.6 inside its own harness. Filter to one model and one harness for a strictly controlled comparison.
Transcribed from the paper's three main results tables, plus the Claude Code deployment table and the skill-learning ablations from the appendix. Pass (%) is strict: every verifier on a task has to pass. Score is the mean verifier pass rate, so the distance between them is partial credit. ± is the population standard deviation over 3 runs — not a standard error, and not a confidence interval. Hours are summed agent duration rather than wall clock, and tokens count input plus output. The record carries no dollar cost, so none is shown. The paper reports Claude Code and the skill-learning ablations separately from its main tables; here they are ranked in the same field, with the model and harness on every row in place of that separation. Two caveats survive the merge. Claude Code changes both model and harness, so it is a controlled comparison only against itself. The skill-learning rows vary only skill provenance and were run on EOG alone, so their ALE columns and overall rate are empty; the paper does not state their task count, and the EOG count in the header is the evolving-skills task set rather than a figure quoted for those rows.
EvoHarnessBench ships 17 streams constructed from two seed benchmarks, EnterpriseOps-Gym and Agentic Last Exam. The construction recipe itself is general: point it at a seed of your own and it gives you an evolving benchmark on the same terms.
Each task needs a verifier-checkable outcome: something that reads the end state and returns pass or fail. If a model scores the answers instead, drift in the grader and drift in the harness draw the same curve — and attributing the change to the harness is the entire purpose.
Per task, the set of capabilities a solution needs. On the tool axis most seeds already ship it as an oracle tool list; on the other two axes you derive it, deterministically, from material the seed already carries. It is construction and analysis material: the agent is never shown it.
Release order comes from frequency, so a suite where every task needs its own private capability has no core and no tail and cannot be staged. What works is the ordinary shape: a few capabilities most tasks touch, a long tail only a handful do.
What you do not need: new tasks, an LLM, a human pass, or a GPU. Construction is parsing, counting and substitution. It runs on a laptop, and anyone holding your seed can reproduce your stream exactly — which is the property that makes a moving harness measurable at all.
A task is dated to a stage whose harness already contains everything the task needs, so a
failure is the system’s and never the schedule’s. That is what makes a score at
stage t comparable to a score at stage t+1.
Because a task lands at the first stage that covers it, its latest-arriving capability is one the stage just introduced. Growth is therefore load-bearing: a system that ignores what appeared cannot pass by repeating what worked last stage.
Both properties fall out of the arithmetic rather than being audited afterwards, which is why the steps are short enough to print whole. What follows is one small module — four steps, no dependency outside the standard library — and then the two public seeds run through it.
Three panels, one file: evolve.py, standard library only, and every axis and every
seed goes through it unchanged. Pick a step.
Everything downstream reads one dictionary: {task_id: capabilities it needs}. Where
that comes from is the only part specific to your seed — the axis panels below and the two
worked examples at the end are all about producing it. This is the part in between, and it earns a
function of its own for one reason: two labels that mean one capability have to become one
string, or the ranking splits a capability most tasks need into two that nobody does.
import json, random
from collections import Counter
from math import ceil
from pathlib import Path
def annotate(tasks, capabilities_of, canonical=lambda c: c.strip().lower()):
"""Step 1, for any axis and any seed: {task_id: frozenset(capabilities)}.
`capabilities_of` is the one function you write. `canonical` folds aliases
together, and it has to happen here, before anything is counted: frequency is
what decides release order, and a split label lands in the tail twice.
"""
ann = {}
for task in tasks:
caps = frozenset(canonical(c) for c in capabilities_of(task) if c)
if caps: # unannotated: skip it rather than guess a stage
ann[str(task["task_id"])] = caps
return ann
Equal buckets over the ranking are the obvious schedule and the wrong one. Two things go wrong on a real frequency distribution, and both are cheaper to handle here than to discover later: a capability two tasks in the suite need cannot carry a stage, and a stage nobody lands on is a rerun of the one before it. The first bucket is also deliberately the largest, and grows further if too little turns out to be covered by it alone — it is the harness that already exists, and a stream starting from three capabilities measures a cold start rather than growth.
def release_schedule(ann, stages=5, first_frac=0.45, min_uses=2, min_new=5):
"""Give every capability the stage it arrives at: {capability: stage}.
Hands back the annotation as well, because dropping a capability drops the
tasks that needed it -- the two have to stay in step.
"""
ann = dict(ann)
while True: # a rare capability is noise, and so is its task
freq = Counter(c for caps in ann.values() for c in caps)
rare = {c for c, n in freq.items() if n < min_uses}
if not rare:
break
ann = {i: caps for i, caps in ann.items() if not (caps & rare)}
if not freq: # the third precondition failing, not a bug: this seed has no core
raise ValueError("nothing survives min_uses: no capability repeats across tasks")
# Core first, long tail last; the name breaks ties so the order can never
# depend on the iteration order of a dict.
order = sorted(freq, key=lambda c: (-freq[c], c))
head = max(1, round(len(order) * first_frac))
tail = order[head:]
per = max(1, ceil(len(tail) / max(stages - 1, 1)))
buckets = [order[:head]] + [tail[i:i + per] for i in range(0, len(tail), per)]
release = {c: t for t, bucket in enumerate(buckets, 1) for c in bucket}
return ann, _merge_thin(ann, release, min_new)
def _merge_thin(ann, release, min_new):
"""Fold away a stage too few tasks land on: it is a rerun of the one before it.
The floor gives way to the stage count, since three stages is the least that is
still a stream -- but a stage nothing at all lands on is folded whatever the
count, because zero is not a judgment call. Folding the first stage forward is
how the initial anchor grows when too little turns out to be covered by it.
"""
while True:
landed = Counter(date_tasks(ann, release).values()) # step 3, same module
live = sorted(set(release.values()))
floor = min_new if len(live) > 3 else 1
thin = next((t for t in live if landed[t] < floor), None)
if thin is None or len(live) == 1:
break
into = next((t for t in live if t > thin), live[live.index(thin) - 1])
release = {c: (into if r == thin else r) for c, r in release.items()}
renumber = {r: t for t, r in enumerate(sorted(set(release.values())), 1)}
return {c: renumber[r] for c, r in release.items()}
The rest is arithmetic. A stage offers everything released up to it, a task is dated to the first
stage that covers it, and the two invariants above are asserted where each row is made rather
than audited after the fact. What it writes is the layout the published corpora use, so a stream
you build this way loads with the same load_dataset call and scores through the same
client as ours — the Evaluation tab.
def date_tasks(ann, release):
"""Each task lands at the first stage that covers it, which is the release
stage of its latest-arriving capability."""
return {i: max(release[c] for c in caps) for i, caps in ann.items() if caps}
def harness(release):
"""What a stage offers: everything released so far, distractors included. This
is what the agent is handed -- never the oracle set, which makes growth free."""
return {t: frozenset(c for c, r in release.items() if r <= t)
for t in range(1, max(release.values()) + 1)}
def stream(ann, release, domain, adapt_ratio=0.3, seed=0, min_adapt=1, min_test=3):
"""One row per task, in the layout the published datasets use."""
H, at, rng = harness(release), date_tasks(ann, release), random.Random(seed)
rows = []
for t in sorted(H):
here = sorted(i for i, s in at.items() if s == t)
rng.shuffle(here)
# Floors on both sides: a stage scored on two tasks says nothing, and a
# stage with nothing to adapt on cannot be adapted to.
k = min(max(round(len(here) * adapt_ratio), min_adapt),
max(len(here) - min_test, 0))
for n, i in enumerate(here):
assert ann[i] <= H[t] # solvable on arrival
assert ann[i] & (H[t] - H.get(t - 1, frozenset())) # needs what arrived
rows.append({"domain": domain, "version": f"v{t}",
"split": "train" if n < k else "test", "task_id": i,
"oracle": sorted(ann[i]), "cumulative": sorted(H[t])})
return rows
def manifest(ann, release, rows, **provenance):
"""What lets anyone check your stream without rerunning your build: what arrived
when, what landed where, and the knobs you used. If a model is any part of your
grader, pin its exact snapshot in here as provenance."""
return {"stages": max(release.values()), "release": dict(sorted(release.items())),
"dated": dict(sorted(date_tasks(ann, release).items())),
"splits": {v: sorted(r["task_id"] for r in rows if r["version"] == v)
for v in sorted({r["version"] for r in rows})}, **provenance}
def write(rows, out: Path, meta=None):
"""v_k/train.jsonl, v_k/test.jsonl and the manifest beside them. That is the
whole of the layout."""
for row in rows:
f = out / row["version"] / f"{row['split']}.jsonl"
f.parent.mkdir(parents=True, exist_ok=True)
with f.open("a") as fh:
fh.write(json.dumps(row) + "\n")
if meta:
(out / "manifest.json").write_text(json.dumps(meta, indent=2, default=list))
The release stage of every capability, the stage each task is dated to, and the split ids. Build twice and diff it: anything that moves is an unseeded shuffle or a missing tie-break, and means your stream is not a fixed object.
The oracle column, the gold capability set, for analysis; and
cumulative, what the agent is actually given — the accumulated pool with
its distractors. Report the gap: it is the cost of growth, and what makes streams comparable.
{domain}_v{stage} with train and test, one row per
task — the shape the Evaluation tab already reads.
The four steps above are axis-blind: they only ever see {task → capabilities}.
What differs per axis is where that mapping comes from, and it gets progressively less free.
Tools are usually already labelled; skills have to be mined out of the prompts; agents are
induced from the tool namespace.
The cheap axis, and the one to start with. Most agentic suites already record which tools a task is supposed to need — an oracle list, a required-software field, a manifest of MCP servers. That field is the annotation, so there is nothing to derive and step 1 is a field read. Everything else about your task rides along untouched.
from evolve import annotate, release_schedule, stream
FIELDS = ("required_tools", "oracle_tools", "tools", "required_software", "apis")
def tool_capabilities(task):
"""Whichever field your suite records the need in -- one of them will be it."""
for f in FIELDS:
if task.get(f):
return task[f] if isinstance(task[f], list) else [task[f]]
return [] # unannotated: annotate() drops it rather than guess
def as_given(task, row, catalog):
"""The substitution that makes the axis bite, and the easiest thing to get
wrong: what the agent receives at this stage is the CUMULATIVE pool, oracle
tools and distractors together. Selection is part of the task.
"""
return dict(task, selected_tools=row["cumulative"],
tool_specs=[catalog[c] for c in row["cumulative"]])
Handing a task its own oracle list instead would make a growing catalog free, and is why the published rows carry both columns: the gold set for analysis, the accumulated pool for the agent. If your seed defines tools per task rather than in one shared catalog, build the catalog first — a capability has to mean the same thing in every stage that offers it.
No seed we have seen ships skill annotations, so this axis is mined — and mined without a model, because a skill invented by an LLM is a skill whose necessity nobody can check. What the seed does carry is procedural knowledge sitting in plain sight, in the prompts. Which route you take depends on whether your tasks share a policy.
Long system prompts carry a procedural policy. Cut it on its own section structure: general instructions stay in the prompt, each procedural section becomes a reference skill, and the prompt is stripped of what was extracted. That removal is what makes the axis real — the procedure is no longer in front of the agent, it has to be found in the pool. A prompt that will not decompose is not a skill stream, which is why only three of our eight enterprise domains have one.
Heterogeneous suites have no common policy to divide. Instead define a small closed catalog of procedural atoms — a canonical procedure plus the fixed lexical patterns and software anchors that identify it. Nothing is removed from the prompt here, so the skills are additive: the question becomes whether the system picks up an offered procedure, not whether it can recover one that was taken away.
Then associate skills with tasks, and use the grader as the signal — whatever the verifier reads is what the task is actually about. Three rules keep that honest, and they are deliberately conservative, since a skill wrongly marked essential inflates every score that follows it.
A specific value the verifier asserts and the skill names is decisive on its own: the task cannot pass without producing exactly what that procedure prescribes.
A field name alone is weak evidence — many procedures touch the same field — so a field-level match counts only when several constraints agree.
Identifiers and audit columns the environment manages are matched by everything and mean nothing. Leave them out or every skill matches every task.
import re
BOOKKEEPING = {"id", "uuid", "created_at", "updated_at", "created_by", "status"}
def mine_skills(prompt, min_chars=400):
"""Route A. Cut a shared policy on its own section structure, and hand back the
prompt with what you cut REMOVED -- a procedure still sitting in front of the
agent is not one it has to retrieve.
"""
parts = re.split(r"\n(?=#{2,3} )", prompt) # your seed's own headings
general, skills = [parts[0]], {}
for part in parts[1:]:
title = part.splitlines()[0].lstrip("# ").strip()
if len(part) >= min_chars: # thinner than this is not a procedure
skills[title.lower().replace(" ", "_")] = part
else:
general.append(part)
return skills, "\n".join(general)
def skill_capabilities(task, skills, grader_reads):
"""Which mined skills a task cannot pass without -- decided by the verifier.
`grader_reads(task)` is the one function you write: a parse of your own grader
into the fields it asserts on and the literal values it asserts. The three rules
above are the three lines below.
"""
checked = grader_reads(task)
need = set()
for sid, body in skills.items():
text = body.lower()
hits = [v for v in checked["values"] if len(str(v)) > 3 and str(v).lower() in text]
fields = [f for f in checked["fields"]
if f.lower() not in BOOKKEEPING and f.lower() in text]
if hits or len(fields) > 1: # a value decides; one bare field does not
need.add(sid)
return need
Then filter, and expect to lose material: a skill no task needs is dropped, a task no skill matches is dropped, and a domain left with fewer than three non-empty stages is not a stream. Ours kept 76.6% of eligible tasks and 42 skills. What survives goes through the same four steps as any other axis. Worth stating plainly: this is a verifier-grounded heuristic, not a proof of necessity or sufficiency — the annotation is strong enough to schedule a stream and to analyze one, and we do not claim more for it.
The agent axis is not mined from tasks at all: it is induced from the tool axis, so build that one first. The observation it rests on is that a tool namespace already encodes ownership — a tool belongs to an app, a service, a product, a library. Group by owner and each owner becomes a specialist worth delegating to, with no new content written for it.
def owner_of(tool):
"""A tool namespace already encodes ownership -- split it. Where a seed has no
namespace, a flat list of software say, canonicalize into families first so a
stack, its CLI and its library all land in one place.
"""
return tool.split(".", 1)[0] # salesforce.create_case -> salesforce
def induce_agents(tool_ann):
"""{owner: its tools} and {task: the owners it needs}. The second one is the
agent-axis annotation, and it goes through the same steps 2-4 as any other."""
bundles = {}
for caps in tool_ann.values():
for tool in caps:
bundles.setdefault(owner_of(tool), set()).add(tool)
agent_ann = {i: frozenset(owner_of(t) for t in caps) for i, caps in tool_ann.items()}
# Ownership has to be a partition and not a tagging, and the induced labels have
# to cover the axis they came from -- otherwise the annotation means nothing.
assert sum(map(len, bundles.values())) == len(set().union(*bundles.values()))
for i, owners in agent_ann.items():
assert tool_ann[i] <= set().union(*(bundles[o] for o in owners))
return bundles, agent_ann
Each bundle then needs a routing blurb the lead agent can read — the owner’s name, what it is for, its tools — and that is the only text this axis asks you to write.
What makes this the hardest of the three is the one deliberate restriction at evaluation: the lead agent gets no tools of its own, only the cumulative pool of specialists, so the work has to be delegated. Ours land at 62 specialists, and 85.2% of agent-axis tasks need more than one of them — coordination pressure that comes out of the seed’s own tool structure rather than from anything we wrote.
Below we take Terminal-Bench and APEX-Agents and construct the EvoHarnessBench version of each: a complete script per seed, importing the module above unchanged.
The capability is the software. Terminal-Bench meets the requirement seeds most often fail: a task carries its own tests and is scored by running them, so pass and fail are settled by execution. What it has no field for is the capability, so that part is derived — from the two files that already say what a task needs, the Dockerfile that builds its environment and the reference solution that proves it solvable.
import re
from pathlib import Path
from evolve import annotate, manifest, release_schedule, stream, write
# Two task layouts are in the wild and both carry the two files we read:
# task.yaml + Dockerfile + solution.sh (terminal-bench)
# task.toml + environment/ + solution/solve.sh (Harbor)
INSTALL = re.compile(r"(?:apt-get|apt|pip3?|npm|cargo|gem)\s+(?:install|add)\s+([^\n&|;]*)")
INVOKE = re.compile(r"^\s*(?:sudo\s+)?([a-z][\w.+-]*)", re.M)
FAMILY = {"pip3": "pip", "python3": "python", "apt-get": "apt", "nodejs": "node",
"g++": "gcc", "psql": "postgres", "libpq-dev": "postgres"}
SHELL = {"cd", "echo", "cat", "set", "if", "fi", "for", "do", "done", "then", "exit"}
def tb_tasks(root: Path):
"""One dict per task directory, either layout. The directory name is the id."""
for d in sorted(p for p in root.iterdir() if p.is_dir()):
env = _first(d / "environment/Dockerfile", d / "Dockerfile")
sol = _first(d / "solution/solve.sh", d / "solution.sh")
if not (env and sol): # no reference solution: nothing states the need
continue
yield {"task_id": d.name, "env": env.read_text(), "solution": sol.read_text()}
def _first(*paths):
return next((p for p in paths if p.exists()), None)
def tb_capabilities(task):
"""Whatever either file installs, plus what the reference solution invokes.
Both are needed -- the Dockerfile has the libraries, the solution has the CLIs --
and tests/ is deliberately never read: pytest is the grader's dependency, not the
agent's, and counting it would put it in every task's annotation.
"""
got = set()
for spec in INSTALL.findall(task["env"] + "\n" + task["solution"]):
got |= {w.split("==")[0] for w in spec.split() if not w.startswith("-")}
got |= set(INVOKE.findall(task["solution"]))
return {FAMILY.get(c, c) for c in got} - SHELL
ann = annotate(tb_tasks(Path("terminal-bench/tasks")), tb_capabilities)
ann, release = release_schedule(ann, stages=5, min_uses=3)
rows = stream(ann, release, domain="terminal_bench")
write(rows, Path("data/terminal_bench"),
manifest(ann, release, rows, enforcement="allowlist-in-instruction"))
Two things are worth doing by hand here. Print the frequency table before you stage anything and
read the top forty labels: canonicalization is the whole game on a mined annotation, and
FAMILY above is a starting point, not a finished map. Then decide how a stage is
enforced, because a container image will happily contain software the stage has not
released yet: either build one image per stage from the cumulative pool, which is the strict
reading, or state the pool as an allowlist in the instruction and hold the image fixed, which is
cheaper and is what our own enterprise track does. Both are defensible. Mixing them across
stages is not.
The capability is the application. APEX-Agents arrives with the namespace already given, and with the grader as the part you have to settle: 480 long-horizon professional tasks across 33 worlds of files and applications — mail, spreadsheets, documents, calendar, chat, code execution — and applications are the shape both the tool axis and the ownership partition want, with 33 worlds supplying the repetition a frequency ranking needs to mean anything.
from pathlib import Path
from datasets import load_dataset
from evolve import annotate, manifest, release_schedule, stream, write
JUDGE = "pin-the-exact-snapshot-here" # a judge that moves moves every score with it
# A closed catalog of the applications a world exposes, each with the cues that
# identify it. Lexical and fixed: no model decides what a task needs.
APPS = {
"mail": ("email", "e-mail", "inbox", "reply to", "sends a message"),
"spreadsheets": ("spreadsheet", "xlsx", "worksheet", "cell", "formula", "tab named"),
"documents": ("document", "docx", "memo", "write-up", "drafts a"),
"presentations": ("presentation", "slide", "deck", "pptx"),
"calendar": ("calendar", "meeting", "invite", "schedules a"),
"chat": ("chat", "channel", "direct message"),
"code": ("script", "python", "notebook", "code execution"),
"pdfs": ("pdf", "scanned", "filing"),
}
def apex_tasks():
"""Take the dataset card's own advice and print ds[0].keys() first -- field names
move. What this reads is the id, the world, the prompt and the rubric criteria.
"""
for row in load_dataset("mercor/apex-agents", split="train"): # gated: accept terms
rubric = row.get("rubric") or row.get("rubric_criteria") or []
criteria = [c if isinstance(c, str) else c.get("criterion", str(c)) for c in rubric]
yield {"task_id": row["task_id"], "world": row["world_id"],
"text": " ".join([row["prompt"], *criteria]).lower()}
def apex_capabilities(task):
"""A criterion that says "the email states X" needs Mail. Grade-relevant text is
the right thing to read: the rubric is what the task is actually about.
"""
return {app for app, cues in APPS.items() if any(c in task["text"] for c in cues)}
ann = annotate(apex_tasks(), apex_capabilities)
ann, release = release_schedule(ann, stages=4, first_frac=0.5, min_new=12)
rows = stream(ann, release, domain="apex_agents")
write(rows, Path("data/apex_agents"), manifest(ann, release, rows, judge=JUDGE))
# The agent axis then comes for free: one specialist per application, since an
# application owns its own actions. induce_agents(ann), then steps 2-4 again.
Freeze the judge model and the rubric before you stage anything, and record both in the manifest.
Scoring here is a rubric of binary criteria adjudicated by a model, so an unpinned judge means a
stage-to-stage difference is reporting two movements at once — and this is the requirement
the seed does not meet on its own. Note also what the catalog size costs you: eight applications
support three or four stages, not ten, because every stage has to introduce something at least
min_new surviving tasks need. For a longer stream, split the namespace finer —
reading a spreadsheet against writing one, searching mail against sending it — which is
exactly what a tool-level namespace hands you for nothing. Their terms also restrict the data to
evaluation, so read them before building adaptation splits out of it.