🏠 Research Hub All papers & projects 🔬 MAS-Zero Designing MAS with Zero Supervision 🔬 MAS-Orchestra Holistic Orchestration & Controlled Benchmarks 📊 Reasoning Survey Inference Scaling, Learning to Reason & Agents

EvoHarnessBench

Can Your Agents Keep Pace with an Evolving Harness?

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.

What makes EvoHarnessBench different

  • Outer harness evolution + optional inner self-evolving adaptation
  • Three axes of harness: tools, skills and agents
  • Two evaluation modes: deployment and self-evolving adaptation
  • Deterministic and widely applicable construction, with no LLM in the loop

Abstract

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).

Three Persistent Gaps

Current agents do not yet keep pace when the harness grows.

Gap 1Harness expansion is not free

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).

Gap 2Self-evolving adaptation remains inconsistent

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.

Gap 3Retention and adaptation can pull in different directions

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.

Key Takeaways

Lesson 1 · DeploymentHarness change itself creates a retention problem

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.

Lesson 2 · AdaptationEffective adaptation depends on both the harness axis and the adaptation context

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.

Lesson 3 · TransferAdaptation gains do not imply retention gains, or vice versa

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.

What it takes to keep pace

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.

BibTeX

@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}
}

Made with by

*Equal contribution  ·  Core contributors  ·  Senior authors
1Salesforce Research  ·  2UNC Chapel Hill (internship at Salesforce Research)  ·  3UNC Chapel Hill  ·  4University of Wisconsin–Madison

How it works

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.

The harness is the outer loop. Adaptation, if any, is inner.
Outer · harness evolution   H1 ⊆ H2 ⊆ H3 tools skills agents
H1core
H2+ mid-tail
H3+ long tail

Capabilities are released from core to long tail. Earlier ones stay as affordances or distractors. This study isolates monotonic growth — nothing is retired.

Inner · adaptation   optional, inside the current stage Ht
Taskx at stage t
Leadunder Ht
Orchestrationsub-agents 1..N
Environmentresponse y
Feedback f · may update persistent state zt
Across · persistent state zt
memory learned skills prompts code

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.

carried across stage
How the benchmarks are built One seed dataset EnterpriseOps-Gym — sliced by frequency into evolving versions, one track per axis.
Two evaluation modes · three axes
RQ1

Does harness expansion itself degrade previously solved tasks?

RQ2

Can self-evolving adaptation keep pace as capabilities arrive?

RQ3

Do retention and adaptation pull in different directions?

RQ4

How do the three axes — tools, skills, agents — differ?

Evaluation framework
Systems Single-agent Multi-agent
Modes Deployment Self-evolving adaptation
Methods Memory Prompt Code

Evaluation Protocol

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.

Deployment evaluation

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?

Self-evolving adaptation

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:

FWT · forward transfer on newly introduced tasks BWT · backward transfer on earlier cohorts SAS · ReAct, Codex, Claude Code MAS · AutoGen, DeLM Memory · Raw, ReasoningBank, MemToolAgent, G-Memory, LEGOMem Prompt · GEPA Code · Meta-Harness

Benchmark Statistics

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.

17
harness streams
802
unique tasks
520
tools
42
latent skills
62
specialist agents

Tools

Executable tools
520
EOG eval tasks
454
ALE eval tasks
63
Domains
Calendar, CSM, Drive, Email, HR, Hybrid, ITSM, Teams, ALE
Bottleneck
Extracting useful tool experience

Skills

Latent reference skills
42
EOG eval tasks
148
ALE eval tasks
68
Domains
CSM, HR, ITSM, ALE
Bottleneck
Engaging the right skills

Agents

Specialist agents
62
EOG eval tasks
148
ALE eval tasks
63
Domains
CSM, HR, ITSM, ALE
Bottleneck
Balancing delegation coverage and selectivity

Task Gallery

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.

Loading the task corpus…

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.

Results

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.

Cross-axis comparison

ToolsSkillsAgents
Deployment expansionAccuracy ↑, cost ↑Little effectEnvironment-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 contextTask-specificTask-specificFinal full pool from the beginning
Observed bottleneckExtracting useful tool experienceEngaging the right skillsBalancing delegation coverage and selectivity

Adaptation gains are relative to the cumulative deployment baseline on EOG. Forgetting is worst-case deployment BWT.

Tools.

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.

Skills.

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%).

Agents.

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).

Obs. 1 · Deployment.

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.

Obs. 2 · Adaptation.

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.

Obs. 3 · Transfer.

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.

Obs. 4–5 · Context & memory.

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.

Pass rate under the cumulative tool harness

Grey = task-specific reference. Green = best overall method. ▣ = multi-agent. Values are pass %.

FWT / BWT

Full table

Obs. 1 · Deployment.

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.

Obs. 2 · Adaptation.

GEPA is the clear gain on EOG (18.9% → 24.1%). Memory methods are modest or mixed; Codex native memory slightly drops pass rate.

Obs. 3 · Transfer.

Mild deployment forgetting. GEPA reaches +14.9% BWT on ALE with −4.0% FWT — preserving old procedures does not imply learning new ones.

Obs. 4–6 · Engagement.

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.

Pass rate under the cumulative skill pool

FWT / BWT

Full table

Obs. 1 · Deployment.

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.

Obs. 2 · Adaptation.

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%).

Obs. 3 · Transfer.

EOG is comparatively favorable (GEPA +14.3% FWT). ALE deployment Codex hits −34.7% BWT, the strongest forgetting in the paper.

Obs. 4–6 · Delegation.

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.

Pass rate under the cumulative agent pool

“—” = not completed within a comparable compute budget.

FWT / BWT

Full table

Case Studies

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.

Evaluate Your Own Agent

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.

Choose an environment
  1. create sessiona fresh, isolated environment
  2. your agent actsMCP tool calls, or a submitted artifact
  3. gradehidden verifiers read the environment
  4. session freedautomatically, on scope exit

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.

EOG · MCP tools, SQL-graded

Dockerized enterprise apps

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.

ALE · sandbox, artifact-graded

A file sandbox

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.

Log in, then MyAuthtoken

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.

Log in to get a key

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.

bash
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.

python
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.

bash
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.

python
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.

python
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.

return nothing it needs

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.

close what you open

mcp.close() before returning, from a finally: — a crash mid-task should still release the session.

report your tokens

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.

python
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.

python
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.

python
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.

python
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.

python
# `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.

python
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.

python
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

Run it yourself, at three levels

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.

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.

All three sit in the EvoHarnessBench collection on Hugging Face.

The same four coordinates

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(...).

python
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.

Coming soon

The local runner

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.

Leaderboard

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.

Construct Your Own Evolving Benchmark

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.

What your seed has to have

a grader, not a judge

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.

a record of what each task takes

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.

capabilities that repeat

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.

The recipe, in four steps

  1. annotatewhat each task needs, out of one universe of capabilities
  2. rank and releasefrequent first; each capability gets the stage it arrives at
  3. accumulatea stage holds everything released so far — nothing is withdrawn
  4. date the taskseach task lands at the first stage that covers it
Solvable on arrival

Nothing is impossible when it appears

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.

And no stage is a rerun

Every task needs something that just arrived

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.

The four steps, as one small module

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.

python · evolve.py
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.

python · evolve.py
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.

python · evolve.py
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))

What that leaves you holding

one manifest

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.

two views of every task

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.

versioned splits

{domain}_v{stage} with train and test, one row per task — the shape the Evaluation tab already reads.

Three axes, one recipe — what changes is the annotation

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.

python · tools_axis.py
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.

Route A · a shared policy in the prompt

Split it, then take it away

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.

Route B · no shared policy

Name the recurring atoms

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 checked value decides

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 bare field needs corroboration

A field name alone is weak evidence — many procedures touch the same field — so a field-level match counts only when several constraints agree.

bookkeeping is ignored

Identifiers and audit columns the environment manages are matched by everything and mean nothing. Leave them out or every skill matches every task.

python · skills_axis.py
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.

  1. ownevery tool maps to exactly one owner — a disjoint partition
  2. bundlean owner becomes a specialist: routing blurb, its tools, its procedures
  3. annotatea task needs the owners of the tools it needed
  4. releasesame schedule, over specialists instead of tools
python · agents_axis.py
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.

Two popular benchmarks, as examples

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.

python · seed_terminal_bench.py
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.

python · seed_apex_agents.py
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.