Skip to content

Add science_fetch: retrieve records and files from the 42 scientific connectors - #237

Merged
KB (KB-syntheticsciences) merged 22 commits into
mainfrom
feat/science-fetch
Jul 29, 2026
Merged

Add science_fetch: retrieve records and files from the 42 scientific connectors#237
KB (KB-syntheticsciences) merged 22 commits into
mainfrom
feat/science-fetch

Conversation

@KB-syntheticsciences

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds science_fetch, closing a gap where the agent could find a scientific record but not retrieve it.

All 42 connectors in src/science/connectors/ already implemented a fetch(id, opts) method, but src/tool/science.ts only ever exposed search() — a repo-wide grep for connector.fetch returned zero hits. Record retrieval was fully written and completely unreachable.

The tool. science_fetch(db, id, format?) routes through the connector registry, so the tool count stays flat as connectors grow and the tool never learns individual database names. Records under 50 KB render inline; larger ones are written to .openscience/fetch/<db>/<id>.<ext> and the tool returns the path plus a key-level summary, so a 2 MB gene record can't flood the context window.

File retrieval. Seven connectors now serve real files rather than JSON metadata, across five different URL shapes:

Connector Formats URL shape
rcsb-pdb pdb, cif different host (files.rcsb.org)
pdbe cif entry-files endpoint
alphafold pdb, cif URLs live inside the JSON response — fetch first, then follow
uniprot fasta, txt query parameter
pubchem sdf uppercase path segment
ensembl fasta different path
kegg fasta path suffix (/aaseq)

alphafold is why format resolution lives inside each connector rather than in a shared id → URL map: a pure function of the id cannot express "read the URL out of the record you just fetched". uniprot is the clearest payoff — its default JSON record measures 1.42 MB against roughly a kilobyte for the same record as FASTA.

Rate limiting. Only arxiv declared a rateLimit before this. science_fetch makes back-to-back retrieval an ordinary action, and a second full pass over the connector set returned HTTP 429 from Semantic Scholar. This adds pacing to semantic-scholar and to all three consumers of the shared eutils.ncbi.nlm.nih.gov host (eutils.ts, pubmed.ts, geo.ts) — pacing only some of them would leave the host unpaced.

Permissions. Spill writes go through ctx.ask({permission: "edit"}). Default rules are "*": "allow", so no normal user sees a prompt, but plan mode's edit: {"*": "deny"} now correctly applies. The spill directory is self-ignoring (a .gitignore containing *), following the existing .openscience/handoffs/ pattern, so fetched data never pollutes git status.

Also included: docs/specs/science-fetch-design.md (design), docs/plans/12-science-fetch.md (implementation plan), and docs/ROADMAP.md (all 110 agreed items tiered with audited per-item status).

How did you verify your code works?

These 42 fetch() implementations had never executed. A one-time recorder (script/record-fetch-fixtures.ts, run by hand, never in CI) called all 42 against live APIs. Result, reproduced across three independent runs: 37 of 42 returned a real record. The five that did not are now documented rather than silently broken:

Connector Outcome Cause
biogrid error BIOGRID_ACCESS_KEY unset
expression-atlas miss reports found: false
depmap miss catalogue answered found: false
myvariant threw HTTP 404 — the sample id no longer resolves
semantic-scholar threw HTTP 429 — keyless tier throttled

Those 40 recorded responses are committed and replayed offline by test/science/connector-fetch.test.ts, which asserts each connector classifies its own real API shape correctly, and asserts disposition against the 50 KB threshold. That is what makes mygene.json (2.3 MB) and uniprot.json (1.6 MB) load-bearing rather than dead weight — they are the real data proving the spill path fires. Exactly six fixtures exceed the cap: uniprot, mygene, sifts, bindingdb, pfam, ensembl.

132 tests across 11 files in test/science/, all passing. Six new test files cover the classification logic, the connector contract, the tool end-to-end (including a successful format request that writes a real .cif), the seven fetchFile connectors, and rate-limit pacing. Rate limits are asserted by measuring the delay they impose — the option is consumed inside http.ts and never reaches globalThis.fetch, so timing is the only honest assertion available.

Every new assertion was verified capable of failing by deliberately breaking the thing under test, observing the failure, and reverting — including the specific mutations most likely to slip through, such as deleting a case transform rather than inverting it.

Merge check: origin/main is 42 commits ahead of the merge-base. The two branches share zero changed files, a trial merge is clean, and the merged result runs 132/132 science tests and 1432 passing overall.

Checklist

  • bun run typecheck passes — no errors from this branch
  • bun test (in backend/cli) passes — test/science/ 132 pass / 0 fail
  • bunx prettier --check . is clean
  • Linked an issue — none identified; happy to link one if this maps to an existing ticket
  • Screenshots or a short video for UI changes — N/A, no UI changes

One note on the suite. The full backend/cli suite shows a single failure, spa fallback > browser navigation to an unmatched route still gets the SPA index.html. I verified it fails identically on plain origin/main with none of this branch's code present, so it is pre-existing and out of scope here.

Follow-ups deliberately not in this PR

  • FetchedFile.contentType and .filename are computed by all seven connectors and consumed by nothing. Worth resolving (consume or remove) when the renderer work lands.
  • In science.ts, fs.mkdir and the .gitignore write still run before ctx.ask, so plan mode creates an empty self-ignoring directory before correctly denying the write. No user data is written; a one-line reorder.
  • docs/ROADMAP.md was audited against 52845c3. main has since added scientific file viewers to the frontend, so at least roadmap item 16 is now out of date and deserves a re-audit.

Ranks all 110 features/fixes into four tiers with a per-item status
verdict audited against the tree at 52845c3: 1 done, 29 partial,
33 skill-only, 47 missing.

Leads with a 'wire what already exists' section, since the dominant
gap is capability that was built and never connected -- connector
fetch() (42 implemented, 0 reachable), the science renderers (only
kind:"image" is ever emitted), session-review.tsx (0 imports), and
the OpenTelemetry flag (no exporter, emits no span).

Documents why a SKILL.md is not an implementation: tool/skill.ts
parses markdown and returns prose, so the 280 helper scripts across
97 skills are never executed by product code.
All 42 connectors implement fetch(); nothing calls it. science.ts
exposes only search(), so the agent can find a record and not
retrieve it.

Design: split record retrieval from file retrieval. fetch() returns
records (inline under 50KB, else spilled to .openscience/fetch/);
new optional fetchFile()+formats serves files (always spilled) for
the 7 connectors that can produce pdb/cif/fasta/sdf.

Includes a throwaway prototype that executed all 42 fetch() impls
against live APIs -- their first execution ever. It falsified the
original rationale: payload size is NOT predictable per connector
(hpa predicted 0.5-5MB, measured 10.6KB; the two largest, mygene
2.15MB and uniprot 1.42MB, were unpredicted), so size must be
measured at runtime. 50KB lands in a real gap: nothing measured
between 46.5KB and 82.4KB, and the split held across two runs.

Run with: cd backend/cli && bun run prototype:fetch
Task-by-task TDD plan for docs/specs/science-fetch-design.md.

Corrects three things the spec got wrong, found while reading the code:
MAX_BYTES is already exported as Truncate.MAX_BYTES so nothing needs
lifting out of read.ts; Tool.define auto-truncates unless metadata.truncated
is set, which a tool that spills its own output must opt out of; and the
eutils host has three consumers (eutils.ts, pubmed.ts, geo.ts) rather than
the four connectors the spec named, so pacing only some leaves the host
unpaced.
Pre-flight scan caught three spots mandating patterns the review rubric
treats as defects.

Task 8 asserted rateLimit by grepping source text, which passes if the
string appears in a comment. The option is consumed inside http.ts and
never reaches globalThis.fetch, so the honest test measures the delay it
imposes -- and the eutils case now exercises all three consumers of the
shared host, which is the reason that scope expanded.

Task 9 skipped arxiv with a bare return, asserting nothing. Its rejection
is absorbed by .catch in the loop, so the skip was unnecessary; its
contract is now asserted explicitly.
Classifies what a connector fetch produced: record, file, clean miss, or
error, and whether a record goes inline or spills. Lifted from a prototype
validated against all 42 connectors' live APIs.

Sentinel handling is the load-bearing part: nine connectors signal 'not
found' with null, {}, or found:false rather than throwing, and conflating
those with failures renders an ordinary miss as an outage.
Records and files have different contracts: a record is structured and may
render inline, a file is opaque and always spills. Splitting them makes the
spill policy structural rather than a threshold applied to opaque data.
All 42 connectors implemented fetch() and nothing called it, so the agent
could find a record and not retrieve it. Routes through the registry, so
the tool never learns individual database names and the tool count stays
flat as connectors grow.

Sets metadata.truncated because Tool.define otherwise runs Truncate.output
over a result this tool has already spilled itself.
The 429 test had no Retry-After header, so http.ts's exponential backoff
(~1s + ~2s + ~4s) made it depend on the repo's --timeout 15000 override
and fail under bare `bun test`'s 5000ms default. Stub Retry-After: "0",
matching test/science/science-tool.test.ts:90, so the retries resolve
immediately. Assertions are unchanged.
ScienceFetchTool's rate-limit classification duplicated the regex already
exported as classifyError from fetch-outcome.ts (Task 1), which exists
precisely so science_search and science_fetch agree and so a fix to retry
detection needs one edit instead of several. Call the helper instead.

Behaviour is identical - classifyError computes retryable/message the same
way, so the existing 429 test is unchanged.
Without this the model has no way to discover which formats exist and
would guess at them.
Replace vacuous negative assertion on '— formats:' (which can never
appear in output) with a real check for '· formats:' separator.
Verified with deliberate break test: guard now catches actual bugs.

Also rename test title to accurately reflect what it checks:
catalog projection preserves the formats key.
RCSB coordinates come from files.rcsb.org, a different host from the JSON
entry API the connector already used.
The existing test used an already-uppercase ID, so the toUpperCase()
transformation was never exercised. Add a test with lowercase input
to properly verify the case normalization.
File URLs are only discoverable inside the JSON prediction record, and the
API returns an array, so the connector fetches the record first and follows
the URL it carries.
…ectors

Four different URL shapes: uniprot takes a query param, pubchem an
uppercase path segment, ensembl a different path, kegg a path suffix.

uniprot is the clearest payoff — its default JSON record measured 1.42 MB
against roughly a kilobyte for the same record as FASTA.
A second pass over all 42 connectors returned HTTP 429 from Semantic
Scholar; only arxiv declared a rate limit. science_fetch makes back-to-back
retrieval ordinary, so this would bite routinely.

The eutils host has three separate consumers -- eutils.ts, pubmed.ts and
geo.ts -- and pacing only some of them leaves the host unpaced.
Every fetch() implementation shipped unexercised. The recorder runs all 42
against live APIs; the offline suite replays the results, so fixtures are
recorded truth rather than authored guesses -- which is what stops a
connector passing its test while failing for a user.

Known-degraded connectors are asserted as such rather than skipped.

Live recorder run (2026-07-29): 37/42 returned a record.

  biogrid           error  BioGRID access key required (BIOGRID_ACCESS_KEY unset)
  depmap            miss   catalogue API answered {found: false} for id CRISPR
  expression-atlas  miss   reported {found: false} for E-MTAB-5214
  myvariant         threw  HTTP 404 for chr7:g.140453134A>T (sample id no longer resolves)
  semantic-scholar  threw  HTTP 429, keyless tier rate-limited after 3 retries

Reproduced across two independent live runs with identical results.

Also allowlists 30 gitleaks false positives in the recorded crossref.json
fixture: CrossRef's own bibliography reference keys (e.g. "BFnature12373_CR1"),
not credentials.
The prior conformance loop mocked every connector's fetch() with a fixed 200
JSON body and asserted the outcome kind was one of a closed 4-member union --
that union is exhaustive, so the assertion passed unconditionally regardless
of what a connector actually did with real data. Meanwhile the 40 committed
fixtures had no consumer, despite the previous commit message claiming the
offline suite "replays the results."

This wires them together: the loop now reads each connector's recorded
fixture and asserts the classification the live recorder actually observed
(biogrid: error, depmap: miss, expression-atlas: miss, all else: record), plus
the record/spill disposition its size implies. That is what makes the fixture
bytes load-bearing -- mygene.json (2.3MB) and uniprot.json (1.6MB) are the
real data proving the 50KB spill threshold fires, not just stored bytes.

Also asserts, rather than leaves silent, that myvariant and semantic-scholar
produced no fixture (both threw against the live APIs; see the previous
commit's per-connector table).

Verified two mutations fail before reverting:
  - editing uniprot's fixture payload to {"found": false} failed its
    classification test (expected "record", got "miss")
  - loosening the disposition threshold to 50MB failed exactly the 6 real
    spill cases (uniprot, pfam, sifts, ensembl, mygene, bindingdb)
The validated classification logic moved to fetch-outcome.ts in the first
task of this branch. The TUI shell is preserved on branch
prototype/science-fetch-repl as a primary source.

It earned its keep: it falsified the original rationale for the size
threshold. Payload size is not predictable per connector -- hpa was
estimated at 0.5-5MB and measured 10.6KB, while the two largest payloads
(mygene 2.15MB, uniprot 1.42MB) were not predicted to be large at all -- so
size has to be measured at runtime rather than annotated.
…lose test gaps

science_fetch's spill write bypassed the permission system (plan mode could
not stop it) and dropped untracked files into the user's repo with no
.gitignore, unlike every other per-session artifact directory. Also fixes
the mismatched error-metadata shape between the network and sentinel error
paths.

Closes three test gaps found in whole-branch review: no end-to-end test of
the format-supplied spill path, a conformance suite whose fixture-vanishing
protection depended on a byte-count check tautologically derived from the
same output it verifies, and no guard against the fixture set silently
shrinking.
The 40 fixtures under test/science/fixtures/ are live API responses replayed
verbatim by the connector conformance suite. Normalising their formatting
fights the recorded-truth property the suite depends on, so they belong with
the other recorded/generated artifacts already listed here (models-snapshot.ts,
the sdk gen output).
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openscience Ready Ready Preview, Comment Jul 29, 2026 5:07pm

Request Review

@KB-syntheticsciences
KB (KB-syntheticsciences) merged commit dc00e07 into main Jul 29, 2026
12 checks passed
@KB-syntheticsciences
KB (KB-syntheticsciences) deleted the feat/science-fetch branch July 29, 2026 17:10
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.

1 participant