Skip to content

DeepScholar-Bench as a first-class task on the shared paper-search agent scaffold - #318

Draft
yilunzhao wants to merge 38 commits into
mainfrom
yilun/deepscholar-unified-arm
Draft

DeepScholar-Bench as a first-class task on the shared paper-search agent scaffold#318
yilunzhao wants to merge 38 commits into
mainfrom
yilun/deepscholar-unified-arm

Conversation

@yilunzhao

Copy link
Copy Markdown
Contributor

What this adds

DeepScholar-Bench as a first-class olmo-eval task, so the same single-agent scaffold that runs litsearch / SAGE / the other paper-search benchmarks also runs DeepScholar — one arm, comparable across all six benchmarks — while every published number still comes from the benchmark's own evaluator.

Three pieces:

  1. arxiv_paper_search tool — searches Semantic Scholar and keeps only arXiv-provenanced papers (client-side externalIds.ArXiv filter), with a per-instance date cutoff so the agent cannot cite work newer than the query paper. Overfetches and pages (offsets 0/100/200) until it has arXiv papers to show. Two-tier output: citable results carry arxiv.org/abs/<id> links; context-only results don't.
  2. arxiv_paper_search_agent harness preset — the shared openai_agents scaffold with only this tool. Kept separate from paper_search_agent so litsearch/SAGE numbers measured against Semantic Scholar search don't move.
  3. deepscholar_bench task + deepscholar_export adapter — the task prompts the upstream generation prompt verbatim over the 63 benchmark queries. The adapter turns each prediction into the exact folder layout upstream's DeepScholarBaseParser reads (final_report.md / intro.md / paper.csv), resolving the model's citations ([n], footnotes, bare URLs) against the trajectory's retrieved sources and rewriting them as [Title](arxiv.org/abs/<id>) links — the only citation form the official scorer credits. Queries whose answer cites nothing resolvable are excluded and counted in exportable_rate; task-level metrics are bookkeeping only, never benchmark scores.

Scoring stays out of band: the export is fed to the pinned official evaluator (guestrin-lab/deepscholar-bench @ c95413b3, python -m eval.main), so reported metrics come from the benchmark's own instrument.

Why Semantic Scholar as the arXiv backend

Paired replay of 200 real agent queries against both backends: S2 gold-hit@10 24.2% vs 19.7% for the arXiv API (n=198, every backbone stratum favors S2). The deciding issues are behavioral: the arXiv API ANDs every query word over metadata and returns confident junk instead of an empty list when nothing matches; its submittedDate filter is silently dropped when combined with multi-word queries (a one-day window returns 646k results with a five-word query, 1 result with a single word); and its rate limiting 429s well-spaced requests after a burst. The approach follows the S2-with-arXiv-filter shim in roryd/deepscholar-bench-s2patch, reimplemented as a harness tool for the agent side.

Status

Draft on purpose: the branch predates current main (known conflict surface: pyproject.toml, uv.lock, cli/beaker/launch.py, evals/tasks/common/base.py, runners/asynq/preparation.py, tests/launch/test_beaker.py); a sync onto main incl. #300 is queued behind an in-flight full-63 run and will update this PR. Opening now to anchor the design discussion — in particular how this unified-agent arm and the external deepscholar_bench eval (which drives the upstream deepscholar_base reference pipeline) should coexist and be labeled.

Validated end-to-end on Beaker: smoke and full-63 generation + official scoring for Qwen3.5-9B (task tests green; suite green at branch tip).

yilunzhao and others added 30 commits July 22, 2026 15:30
Auditing a real 200-generation run surfaced scaffold leaking into the text
handed to both judges: every gemma-4 report carried channel markers (three
were nothing but markers yet were judged as reports), and one Qwen report
ended in dangling tool-call tags. Extraction now removes tool-call, function,
parameter, channel and role markers deterministically, keeps only final-channel
content while preserving text written before it, and never alters code spans.

Also stop the generation instruction from teaching a citation form models copy
verbatim: the example placeholders can no longer be echoed as a usable citation,
and clean_citation_url strips angle brackets so CommonMark autolinks validate
instead of silently dropping out of the FACT denominator.
…ilures

A live run produced a validation list of the right length whose items lacked
the 'result' key; the KeyError escaped and the harness zeroed the whole
instance, discarding its already-computed RACE scores (the official pipeline
crashes outright on the same input). Malformed items now retry and fall back
to all-unknown like other parse failures, and an unexpected FACT error zeroes
only the FACT channels for that instance.
(cherry picked from commit b563f80)
(cherry picked from commit 2d9b7d6)
(cherry picked from commit 48c1c7b)
(cherry picked from commit 197b09e)
(cherry picked from commit 7140aa6)
(cherry picked from commit f9b2403)
(cherry picked from commit 8820a47)
FACT extracts every (statement, reference, url) triplet from a report, deduplicates
them, crawls each cited URL and judges support one statement at a time. RACE needs
one judge call per instance. The cost difference is orders of magnitude, and there
was no way to ask for RACE alone.

Right now most of what FACT would crawl is already known to be fabricated: one run
cites www.example.com 1,023 times and collapses 638 arXiv citations onto 32
distinct IDs, so scoring it would spend heavily to re-confirm a defect that was
established for free by reading the generations.

Gate it on DEEPRESEARCH_SKIP_FACT rather than a TaskConfig field, because
TaskConfig is the shared base for every task and a knob only this one understands
does not belong there. Default is unchanged, so an unset environment still runs
both.

A skipped run records fact_details as {"fact_scoring": "skipped"} and logs a
warning naming the count, because zeroed metrics that silently mean "not run" are
exactly what made an earlier all-zero column unreadable.
researchqa.py already reads OLMO_EVAL_JUDGE and falls back to its default spec.
This task hardcoded gpt-5.5 for RACE and gpt-5.4-mini for FACT, so scoring the two
benchmarks in one comparison with the same judge meant editing a constant.

Read the same variable here, keeping both defaults and both effort levels, so an
unset environment behaves exactly as before.
Generation and scoring are coupled in `run`, so obtaining judge scores for work
already finished meant regenerating it: burning GPU to produce different answers
and then scoring those instead of the ones that were analysed. One DeepResearch
Bench cell takes about 17 hours to regenerate for the heaviest system.

Match on a sha256 of the canonical request payload, tagged chat or text, which is
exactly what the runner writes to requests JSONL after applying harness config.
Tools, sampling params and system prompt are excluded from the key because they
describe how to generate rather than which instance; any collision they would have
disambiguated is caught at load, where two entries sharing a key with different
stored text is an error. The request-to-prediction join runs on doc_id with
native_id as a witness, so a one-off shift between the two files raises instead of
answering every instance with its neighbour's report.

A missing prediction aborts the batch and names whether the instance was unknown or
merely absent. There is no flag to continue past it, because there is no honest
value to return. A prediction that is genuinely an empty string replays as empty
and is counted separately; a null or absent one counts as missing. logprobs raise
rather than fabricate.

Trajectories are re-attached to output metadata: DeepResearch Bench does not read
them, but litsearch and sage score against response.trajectory, which the runner
rebuilds from that field.

Round-trips 100/100 byte-identical against both real DRB prediction files, with
negative controls for a dropped row and a one-character prompt change. The requests
side of that check was reconstructed through the task's own format_request and the
runner's build_requests, since no saved requests file was available on the host;
a mismatch against genuine artifacts would surface as a loud coverage error rather
than a wrong score.
Our runs drew roughly 5,000 HTTP 429s from Semantic Scholar. Answers survived,
because the retry ladder absorbs them, but each exhausted retry sleeps through a
1/2/4/8/16 second backoff and one system's batches ran 35-170x slower than the
single-agent pipelines.

Read S2_API_KEY plus any numbered S2_API_KEY_<n>, each of which may itself hold a
comma-separated list, because Beaker mounts secrets one value per variable while a
local .env is easier as one string. Blanks are dropped and duplicates collapse, so
the same key configured twice cannot inflate the request rate.

Selection cycles rather than drawing at random: the limit is metered per key, and
independent draws land on the same key twice in a row one time in N, re-firing the
429 the second key was added to avoid. The cursor starts at a random offset so
separate processes do not begin on the same key.

Rotation alone would have changed nothing. The rate gate enforced a process-global
1.1 second floor between all requests, so two keys would have split one key's
budget; the interval is now divided by the number of configured keys. Four requests
take 3.30s on one key and 1.65s on two, while each key still sees at most one
request per 1.1 seconds.

Behaviour with zero or one key is unchanged, including the keyless public-API path.
Serper is deliberately untouched: no rate-limit failures have been observed there,
it bills per account rather than per key, and its call sites error on a missing key
rather than falling back silently.

This assumes the keys belong to separate accounts. If they share a quota the gate
now pushes twice as hard into it, which the retry ladder would absorb but which is
worth knowing.
When vLLM runs from /opt/vllm-venv, only /opt/venv/bin is on PATH, so console
scripts installed next to vLLM are invisible to the server process. flashinfer
JIT-compiles kernels on the first forward pass by shelling out to a bare
"ninja", which execvp resolves through PATH. Models with JIT-compiled kernels
(Mamba/GDN linear attention) therefore killed the engine with
"FileNotFoundError: ninja" right after the server had passed its readiness
probe, failing every instance while the job still reported success.

Prepend the interpreter's bin directory to the child PATH, never replacing it
so the main venv stays reachable, warn at startup when ninja is still not
resolvable, and install ninja into the isolated venv explicitly so the fix does
not depend on a transitive dependency of whichever vLLM build lands there.
Gantry derives a job's code version from the git repository rooted at the
launching process's working directory: launch_experiment falls through to
GitRepoState.from_env, which does Repo(".") and takes str(repo.commit()).
olmo-eval never passed gantry's own ref parameter, so the same command run from
a second checkout of this repo launched that checkout's HEAD instead, with
nothing in olmo-eval's output to show it.

Add BeakerJobConfig.git_ref, forwarded to launch_experiment as ref, and log the
commit that will actually be cloned, including the working directory it was
derived from, before every launch. Note that passing a ref also disables
gantry's dirty-repo guard, which only runs when ref is None.
An inference server that dies mid-run leaves every instance failing inside the
per-instance error handler, so the process exited 0 with an empty metrics file
and Beaker reported the job SUCCEEDED. Results are already written and uploaded
by the time run() returns, so failing here costs no artifacts.
…e time

The base image is a CUDA runtime image and ships no nvcc, so FlashInfer's JIT
path cannot succeed on it at all. Making ninja reachable only moved the failure
from "ninja: not found" to "/usr/local/cuda/bin/nvcc: not found" inside the
ninja build, still mid-run and still after the readiness probe passed.

flashinfer-jit-cache ships those kernels prebuilt. When it is installed,
JitSpec.is_aot is true and build_and_load() loads the .so directly, so ninja and
nvcc are never invoked. Install it into the isolated vLLM venv, deriving the
version from the flashinfer and CUDA versions actually present rather than
pinning one, because flashinfer refuses to import when the two disagree. The
step runs after provider overrides and never fails the job.

Verified against flashinfer-jit-cache 0.6.6+cu128: it contains
gdn_prefill_sm90/gdn_prefill_sm90.so, the .so needs only libcudart/libcuda/libc
and no toolchain, and its cubins are sm_90a.
The prompt hardcoded an "Available tools" block naming
serper_google_webpage_search and serper_fetch_webpage_content and told the
model to search and fetch with them. Under paper_search_agent, which exposes
only semantic_scholar_snippet_search, the model obeys the prompt over the tool
schema and calls a tool that does not exist; the openai_agents scaffold then
ends the episode with "[Tool error: Tool serper_google_webpage_search not found
in agent openai_agents]" and an empty trajectory, so every instance scores near
zero without measuring anything.

Carry the harness tool names into TaskConfig and build the prompt's tool list
and workflow steps from them, falling back to the previous hardcoded list when
the harness exposes no tools. dr_tulu exposes exactly those three tools, so its
prompt and every non-agentic prompt stay byte-identical; a test pins that.

Verified locally against Qwen3.5-35B-A3B with paper_search_agent on 10
instances: before, 10/10 answers were the tool-not-found error with turns=[];
after, 10/10 are ~2k-character cited answers whose trajectories contain only
semantic_scholar_snippet_search calls.
ResearchQA hardcoded an Available tools block naming the two serper tools, so under
paper_search_agent the model called a tool that does not exist and every instance
ended before its first turn. The merged branch builds that list from the harness
tool names instead, leaving dr_tulu and no-tool runs byte-identical.
…on test

DeepScholar-Bench credits a source only if it carries arXiv provenance and was
published strictly before the query's cutoff, so a search tool that shows the
model anything else is inviting citations the scorer will throw away.

arxiv_paper_search reuses the Semantic Scholar client, keys, rate gate and
backoff already in this module and changes only what it admits: arXiv
provenance decided client-side on externalIds.ArXiv, never on the venue field,
which an arXiv preprint loses the moment it appears at a conference. The date
bound is the half S2 can enforce, pushed one day short of the cutoff because
its ranges are inclusive and the benchmark's test is strictly-before; the
client-side pass repeats it and falls back to the month the arXiv ID encodes
when S2 reports no date. Because provenance has no server-side form, the
requested page is widened fivefold and trimmed after filtering, which preserves
S2's relevance order rather than re-ranking it.

Results carry the arxiv.org/abs URL, the only form the benchmark's citation
parser credits. Hits without an arXiv ID are not silently dropped -- a couple
are shown marked as context the answer cannot cite, since a search that returns
nothing is indistinguishable from a broken one.

The new preset stays separate from paper_search_agent rather than joining it:
which search tools an agent holds changes what it retrieves, so sharing the
preset would move litsearch and SAGE numbers measured against S2 search.
63 arXiv papers, each supplying its abstract and its own publication date; the
model writes that paper's Related Works section citing only arXiv work
published before that date. The per-instance cutoff reaches the search tool
through retrieval_date_cutoff, so retrieval is constrained the same way the
scorer is.

The prompt is byte-identical to the one lit-agents runs, which is the only
reason the two systems' numbers can be compared at all; a golden-string test
pins it so a reworded prompt fails loudly instead of silently producing
incomparable results.

Scoring is deliberately a placeholder. The published metrics are a second pass
outside this repo, and deepscholar_export writes the per-query
{intro.md, final_report.md, paper.csv} folders that pass reads. What the task
does compute is whether a response is exportable at all, so a run that
generated nothing cannot be mistaken for one that scored zero.

paper.csv holds every arXiv source the tool showed, not only the cited ones,
because the contract the scorer checks is that cited IDs are a subset of the
file. Dates come from the arXiv ID and so carry month precision -- the same
fallback lit-agents uses, and all the rendered tool output can support.
paper.csv carried month precision for every source, because the month was all
the arXiv ID could encode. The benchmark contract rejects a month-precise
source dated inside the cutoff's own month, so any paper published in that
month was discarded -- not because it failed the policy, but because the export
had thrown away the day Semantic Scholar had already returned.

arxiv_paper_search now renders a Published line and says which kind of date it
is: "Published: 2025-04-03" when S2 dated the paper, "Published: 2025-04 (month
precision)" when only the arXiv ID could, and no line at all when neither can.
The export parses it straight into date_precision, falling back to the ID's
month for predictions saved before the line existed.

The preset still declares no S2 secret. required_secrets is a hard launch gate
with no optional form, and declaring one would make a keyless run impossible,
so a key stays a per-run --secret-env mapping; the docstring now says so
instead of leaving it to be discovered.
…e it

The export scored zero and the tests agreed with it. DeepScholarBaseParser
credits a citation only as a markdown link whose URL matches arxiv.org/abs, and
returns no documents at all for a query without one. The prompt -- correctly,
verbatim -- mandates "[3]"-style numbered citations. Every folder this exporter
wrote was therefore unscoreable, and exportable_rate reported otherwise because
it looked for an arxiv.org URL anywhere in the text rather than for a citation
the parser would see.

deepscholar_citations ports render_intro's semantics: strip the reference list,
resolve each inline citation, rewrite the resolved ones as
[Title](https://arxiv.org/abs/<id>), delete the rest. What it cannot port is
where the numbering comes from -- lit-agents' graphs publish a citation_order,
and nothing here does -- so the answer's own reference list is parsed back out
and matched to retrieved sources by arXiv ID, then by title. intro.md and
final_report.md are the rewritten text; exportable_rate runs the same core and
counts the answers it succeeds on.

Version suffixes are normalised in generated URLs and when matching, because the
parser keys its reference map on the raw URL text and compares it against
paper.csv's normalised id: a v2 would silently resolve to an empty title.

The admission test now mirrors _classify_source properly. A hit missing a title
or an abstract is rejected rather than shown, since it can only become a
paper.csv row that fails the contract. A publicationDate that is present but
unreadable is rejected too, instead of falling back to the month its arXiv ID
encodes -- that fallback is for papers Semantic Scholar cannot date at all, and
reaching for it here admits post-cutoff papers on the strength of a date nothing
read.

paper.csv carries only cited sources, its snippet is the full abstract re-fetched
from Semantic Scholar rather than the truncated preview the agent saw, and
snippet_source records the rows where that lookup failed. export_manifest.json,
summary.json and generation_manifest.json mirror lit-agents' own so the output
can go to its preflight and not only to the parser.

Sources are read only from the search tool's own results; another tool naming an
arXiv ID never showed the agent a citable paper. IDs are read from their own
line, so an abstract containing the block separator costs a fallback snippet
rather than the source. A prediction without a positional native_id now raises:
doc_id is a within-run counter, and falling back to it scores one paper's answer
against another paper's ground truth.

The pinned parser is vendored into tests/evals/tasks/fixtures and the
end-to-end test runs it, because asserting this against a hand-written imitation
would only prove the imitation self-consistent.
Two findings from 200 real recorded queries, both about the gap between what
this tool is and what it looked like.

Only 19.4% of the rows S2 returns carry an arXiv external ID. One widened page
was sized for a density that does not exist: 77 of the 200 queries yielded
fewer than ten arXiv candidates, and 11 came back empty from the filter alone,
having had rows to filter. The tool now pages -- offset 0, 100, 200 -- stopping
as soon as it has what the caller asked for, when S2 returns a short page, or
when the budget runs out. Three pages of 100 expects around 58 arXiv rows at the
measured density, and offset 200 plus limit 100 stays inside S2's cap of 1000.
Pages are consumed in order and appended, so relevance order survives. A page
that fails after the first is logged and the earlier pages are still returned;
only an immediate failure has nothing to report.

All 12 operator-bearing queries in that sample came from the sol single agent,
and 8 of them returned zero rows: site: filters, quoted phrases, trailing
"arXiv" hints. The model thought it was talking to a web search engine, and
nothing in the tool description told it otherwise. Now it does.

The query still goes to S2 exactly as written. Stripping operators in code would
turn a measured, attributable failure into a silent rewrite, and the next person
reading a trajectory would not be able to see what the model actually asked.
Twelve findings from a second review, most of them variations on one theme: the
export trusted things it had not checked.

The strictness was in the wrong layer. The tool rejected a publicationDate it
could not read, which contradicted the admits() mirror it claimed -- the
reference parser returns None for an unreadable date exactly as it does for an
absent one, and falls back to the month the arXiv ID encodes in both cases.
Retrieval is now lenient again and the strict test moved to where it belongs:
the exporter revalidates every cited row against the query's cutoff, so a paper
admitted on its ID month and then dated by the batch re-fetch to after the
cutoff no longer exports as a success.

Dropping a row used to leave its citation standing in intro.md, which is a
folder that contradicts itself -- the contract checks that every cited ID has a
row, and the parser would render a citation with an empty title and snippet. The
rewrite now re-runs with the failed source withheld, so the citation leaves with
the row.

Identity was inferred from any line shaped `arXiv: ID`, including lines inside
an abstract, so a paper could rename itself and a context-only result could
smuggle itself back in as citable. Identity now comes from a block's header
alone, and the renderer puts every identifying field before the abstract so the
one field a paper controls the text of is terminal.

Only the exact heading `References` was stripped, so a `## Bibliography` tail
stayed in the prose and every `[1]` in it became a fabricated citation.
Broadened to the headings models actually write, with a warning when the
stripped heading is not the one lit-agents would have stripped.

Also: rendered links are no longer reprocessed into `[Title](url)(url)`; `[1-3]`
expands and `[^1]` resolves, while superscripts and author-year brackets are
counted into a metric rather than silently scoring zero; pagination dedupes
repeated papers and rotates keys per page instead of hammering one at the
interval meant for the whole set; a late page of unreadable JSON no longer
discards the pages that worked; an unreadable prediction row stops the export
instead of quietly shrinking it; and a non-empty output directory is refused
unless --force clears it, because a stale folder fails preflight in the name of
a run that is not at fault.

The reference validators are vendored into the fixtures and run against real
exports. The suite had been exercising our own reimplementation of the contract,
which could only ever prove it self-consistent.
BeakerStatusReporter guarded on whether a Beaker client could be
configured, but a workstation with beaker set up passes that test and
then crashes the worker on the missing BEAKER_WORKLOAD_ID -- which is
the condition that actually decides. Check the workload id first; the
reporter is a documented no-op everywhere else.
… error

gpt-5.6-sol refuses function tools and a reasoning effort together on
/v1/chat/completions, and nothing on this branch could say otherwise. The runs
that looked like they had said otherwise were carrying
OLMO_EVAL_REASONING_EFFORT, which appears nowhere in src/ and read nothing; they
worked because the server default happened to agree at the time.

scaffold_kwargs already reaches the scaffold, so model_settings goes there and
build_model_settings turns it into the SDK's ModelSettings. reasoning_effort is
accepted as the flat key an operator types and becomes
ModelSettings.reasoning.effort, which OpenAIChatCompletionsModel sends as a
top-level reasoning_effort argument. It is deliberately not routed through
extra_args: that call site splats extra_args in next to its own explicit
reasoning_effort= keyword, so the two would collide on this exact key. An
unknown key raises rather than being dropped, because a knob that silently does
nothing is the failure this replaces.

Nothing is pinned in the preset -- backbones that reason well should keep doing
so -- and the docstring names the flag instead:

    -o scaffold_kwargs.model_settings.reasoning_effort=none

Separately, the response-shape check added last round was wrong about what
Semantic Scholar sends. A query matching nothing comes back as
{"total": 0, "offset": 0} with no data array, which the check read as a
malformed response and reported to the model as a search error. A search that
found nothing and a search that broke are different things to an agent choosing
what to do next, so the missing array is now an empty page. A data field that is
present and not a list is still an error.

Both were caught by running the thing rather than testing it: the first live
run died because this commit's own helper had been inserted between
@register_scaffold and the class it decorates, so the registry handed every
worker the helper. Direct imports of both names kept working, which is why the
unit tests were happy.
openai-agents 0.7.0 rejects openai>=2.54 (InputTokensDetails gained a
required cache_write_tokens field). The ~=2.21 pin sat only in the
clients extra, which Beaker launches never install, so containers
resolved 2.54.0 and every agent call failed with a ValidationError
while local venvs kept the working combination. Same lesson as the
lit-agents agents extra, applied to the extra the launcher uses.
openai-agents 0.7.0 rejects openai>=2.54 (InputTokensDetails gained a
required cache_write_tokens field). The only pin lived in the clients
extra, which Beaker launches never install, so containers resolved
2.54.0 and every agent call failed with a ValidationError while local
venvs kept the working combination. The litellm extra now carries the
pin and is declared in conflict with openhands, whose openai==2.8
cannot coexist; the lock re-resolves cleanly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants