@cyanheads/pubmed-mcp-server 2.10.6 → 2.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,7 @@ import { parsePmcArticle } from '../../../services/ncbi/parsing/pmc-article-pars
28
28
  import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers.js';
29
29
  import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
30
30
  import { getUnpaywallService, } from '../../../services/unpaywall/unpaywall-service.js';
31
+ import { fitWholeItems } from './_budget.js';
31
32
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
32
33
  import { pmidStringSchema } from './_schemas.js';
33
34
  import { escapeMarkdownInline, sliceCodeUnits } from './_text.js';
@@ -251,7 +252,10 @@ const UnavailableReasonSchema = z
251
252
  'parse-failed',
252
253
  'service-error',
253
254
  ])
254
- .describe('Why no full text was returned. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data).');
255
+ .describe('Why no full text was returned — the most specific signal any tier that answered reported. not-found: upstream returned no record for this ID. no-pmc-fallback-disabled: every tier was skipped (`triedTiers` is all `not-attempted`) — typically because EPMC (`EUROPEPMC_ENABLED`) and Unpaywall (`UNPAYWALL_EMAIL`) are not configured. no-epmc-fulltext: EPMC indexed the record but publishes no fullTextXML. no-body: the record was retrieved but carries front matter and abstract only, with no body sections — use `pubmed_fetch_articles` for the metadata. no-doi: no DOI to query Unpaywall. no-oa: Unpaywall has no OA copy. fetch-failed: download failed. parse-failed: extraction empty. service-error: upstream server failure (threw, timed out, or returned malformed data). A reason never means the chain ran to completion — read `unqueriedTiers` for that.');
256
+ const UnqueriedTierSchema = z
257
+ .enum(['europepmc', 'unpaywall'])
258
+ .describe('A fallback tier this deployment has not configured');
255
259
  const TierOutcomeSchema = z
256
260
  .enum([
257
261
  'not-attempted',
@@ -282,6 +286,10 @@ const UnavailableSchema = z
282
286
  triedTiers: z
283
287
  .array(TriedTierSchema)
284
288
  .describe('Per-tier outcomes the chain produced for this id, in execution order. Covers `pmc`, `europepmc`, and `unpaywall` — the same tiers the tool description references. Tiers that the chain skipped appear as `outcome: not-attempted` with a `detail` explaining why.'),
289
+ unqueriedTiers: z
290
+ .array(UnqueriedTierSchema)
291
+ .optional()
292
+ .describe('Tiers the chain skipped because this deployment has not configured them, and that could have served this id — the search was incomplete, and a deployment with these tiers configured may still resolve the id. `triedTiers` carries which environment variable each one is waiting on. Absent when every tier that could have served the id was actually queried; a tier skipped because it was inapplicable to this id (no DOI for Unpaywall) is never listed.'),
285
293
  })
286
294
  .describe('One identifier that could not be returned, with the full chain it traversed');
287
295
  // ─── Character-budget schemas ────────────────────────────────────────────────
@@ -341,6 +349,28 @@ const TruncationSchema = z
341
349
  .describe('Per-article accounting, covering only the articles the budget shortened'),
342
350
  })
343
351
  .describe('Character accounting for full text the budget shortened. Present only when a budget actually removed characters — its absence means every returned article carries its full post-filter body.');
352
+ const DeferredSchema = z
353
+ .object({
354
+ maxResponseCharacters: z
355
+ .number()
356
+ .describe('The `maxResponseCharacters` ceiling this response was budgeted against'),
357
+ returnedCharacters: z
358
+ .number()
359
+ .describe('Serialized characters the returned article records account for'),
360
+ deferredCount: z
361
+ .number()
362
+ .describe('Articles the chain resolved but withheld to stay under the ceiling'),
363
+ idType: z
364
+ .enum(['pmid', 'pmcid', 'doi'])
365
+ .describe('Which input branch the deferred ids belong to — re-submit them as `pmids`, `pmcids`, or `dois` respectively. Matches the `idType` on `unavailable` entries.'),
366
+ ids: z
367
+ .array(z.string())
368
+ .describe('Identifiers of the deferred articles, in response order, keyed as they were requested (PMC IDs in `PMC<digits>` form). Re-call `pubmed_fetch_fulltext` with these under the `idType` branch and the same other inputs. Never contains an id from `unavailable`.'),
369
+ nextDeferredCharacters: z
370
+ .number()
371
+ .describe('Serialized size of the next deferred article — the first entry in `ids`, where the response stopped. Raise `maxResponseCharacters` to at least this to make progress; a smaller article further down `ids` cannot be reached until this one fits.'),
372
+ })
373
+ .describe('Continuation state for articles the whole-response budget withheld. Present only when `maxResponseCharacters` deferred at least one article.');
344
374
  /** True when the request asked for any budget at all. Without one, every budget
345
375
  * helper returns its input untouched so the response is byte-identical. */
346
376
  function budgetRequested(budget) {
@@ -512,6 +542,29 @@ function buildTruncationNotice(truncation) {
512
542
  ].filter((k) => k !== undefined);
513
543
  return `Full text was shortened to fit the requested character budget: ${truncation.returnedCharacters} of ${truncation.originalCharacters} body characters returned across ${subject} in ${truncation.mode} mode.${omitted} See \`truncation\` for per-article and per-section counts, and raise ${knobs.join(' or ')} or narrow \`sections\` to retrieve more.`;
514
544
  }
545
+ /**
546
+ * Compose the recovery notice for articles the whole-response budget withheld.
547
+ * Names what was spent, which identifiers are still retrievable, and the ceiling
548
+ * the next call has to clear — so a caller reading only `content[]` can resume
549
+ * without inspecting `deferred`. (#100)
550
+ */
551
+ function buildDeferralNotice(deferred) {
552
+ const spent = deferred.returnedCharacters === 0
553
+ ? `No article fits the requested maxResponseCharacters of ${deferred.maxResponseCharacters}, so none were returned.`
554
+ : `Response character budget reached: ${deferred.returnedCharacters} of ${deferred.maxResponseCharacters} characters returned.`;
555
+ return `${spent} ${deferred.deferredCount} resolved article(s) were deferred whole: ${deferred.ids.join(', ')}. Re-call pubmed_fetch_fulltext with those ids under \`${deferred.idType}s\` to retrieve them, or raise maxResponseCharacters to at least ${deferred.nextDeferredCharacters} — the size of the next deferred article.`;
556
+ }
557
+ /**
558
+ * Body sections an article's per-article budget dropped, derived from that
559
+ * article's own accounting by the rule {@link applyPmcBudget} counts by:
560
+ * `outline` mode keeps every heading, so it drops none. Used to take a deferred
561
+ * article's contribution back out of the response-level roll-up. (#100)
562
+ */
563
+ function countOmittedSections(entry, mode) {
564
+ if (mode !== 'truncate')
565
+ return 0;
566
+ return (entry.sections ?? []).filter((s) => s.originalCharacters > 0 && s.returnedCharacters === 0).length;
567
+ }
515
568
  // ─── Tool Definition ─────────────────────────────────────────────────────────
516
569
  /**
517
570
  * Compose the tool description for the fallback tiers enabled in this
@@ -546,7 +599,8 @@ export function buildFulltextDescription(tiers) {
546
599
  ? '; DOIs with no PMC copy recover via Unpaywall open access'
547
600
  : '';
548
601
  const input = `Provide exactly one of \`pmcids\` (PMC IDs directly), \`pmids\` (PubMed IDs, auto-resolved), or \`dois\` (DOIs, auto-resolved to PMC via the ID Converter${doiTail}).`;
549
- return `${base} ${fallback} ${input}`;
602
+ const budget = 'Two independent character controls: `maxCharacters` caps body text per article, `maxResponseCharacters` caps the whole response and defers articles past the ceiling whole, listing them in `deferred.ids` for a follow-up call.';
603
+ return `${base} ${fallback} ${input} ${budget}`;
550
604
  }
551
605
  const serverConfig = getServerConfig();
552
606
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
@@ -605,7 +659,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
605
659
  .min(1)
606
660
  .max(1_000_000)
607
661
  .optional()
608
- .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, so semantic filtering is unaffected. The response-wide ceiling is this value times the number of articles returned. Omit for the full body.'),
662
+ .describe('Per-article budget for body text, in characters. Counts `source=pmc` section and subsection text, or the `source=unpaywall` `content` body; titles, abstracts, identifiers, and references are never counted or shortened. Applied after `sections`, `maxSections`, and `includeReferences`, so semantic filtering is unaffected. This knob alone bounds only bodies: the response-wide ceiling it implies is this value times the number of articles returned, plus every uncounted field. Use `maxResponseCharacters` for a true whole-response ceiling. Omit for the full body.'),
609
663
  maxCharactersPerSection: z
610
664
  .number()
611
665
  .int()
@@ -613,6 +667,13 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
613
667
  .max(1_000_000)
614
668
  .optional()
615
669
  .describe('Budget for a single top-level body section, in characters, counting the section text plus its subsections. Combine with `maxCharacters` to cap both one section and the article; the tighter of the two wins. Applies to `source=pmc` results only.'),
670
+ maxResponseCharacters: z
671
+ .number()
672
+ .int()
673
+ .min(1)
674
+ .max(1_000_000)
675
+ .optional()
676
+ .describe('Opt-in ceiling for the whole response, in characters — the true response-wide counterpart to the per-article `maxCharacters`. Each article is measured as the JSON record it is returned as, after every filter and the per-article body budget: title, abstract, body sections, references, identifiers, license and source metadata — every field it carries. One ledger covers all tiers, so PMC-, Europe PMC-, and Unpaywall-served articles spend the same budget. Articles are kept in response order until the next one would cross the ceiling; that article and the rest are deferred whole (never partially populated) and listed in `deferred.ids`. Response envelope fields — counts, `unavailable`, `truncation`, `deferred` itself — are not counted. Omit to return every resolved article.'),
616
677
  overflowMode: z
617
678
  .enum(['truncate', 'outline'])
618
679
  .default('truncate')
@@ -623,27 +684,31 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
623
684
  }),
624
685
  output: z.object({
625
686
  articles: z.array(ArticleSchema).describe('Full-text articles'),
626
- totalReturned: z.number().describe('Number of articles returned'),
687
+ totalReturned: z
688
+ .number()
689
+ .describe('Number of articles in this response. Under a `maxResponseCharacters` budget this counts the kept articles only; `deferred.deferredCount` covers the rest.'),
627
690
  unavailable: z
628
691
  .array(UnavailableSchema)
629
692
  .optional()
630
- .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from.'),
693
+ .describe('Per-identifier explanations for any requested PMIDs, PMCIDs, or DOIs with no returnable full text. `idType` discriminates which branch the id came from. Distinct from `deferred`: nothing here is retrievable by re-calling, and an id never appears in both.'),
631
694
  truncation: TruncationSchema.optional(),
695
+ deferred: DeferredSchema.optional(),
632
696
  }),
633
- // Recovery guidance for three cases — a `sections` filter that removed every
697
+ // Recovery guidance for four cases — a `sections` filter that removed every
634
698
  // body section (#80), a record the chain could only retrieve as front matter
635
- // (#86), and a body the character budget shortened (#81). Agent-facing context
636
- // surfaced via ctx.enrich.notice() to structuredContent and content[]; absent
637
- // when none applies.
699
+ // (#86), a body the per-article character budget shortened (#81), and articles
700
+ // the whole-response budget withheld (#100). Agent-facing context surfaced via
701
+ // ctx.enrich.notice() to structuredContent and content[]; absent when none
702
+ // applies.
638
703
  enrichment: {
639
704
  notice: z
640
705
  .string()
641
706
  .optional()
642
- .describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. Absent when none of those applies.'),
707
+ .describe('Optional guidance for a partial or empty body. A `sections`-filter miss names the requested terms and affected article id(s) and suggests retrying without `sections` or using broader headings. A metadata-only record names the id(s) the chain could retrieve as front matter only and points at `pubmed_fetch_articles` for the abstract. A budgeted response names the characters returned versus carried and points at `truncation`. A response-wide budget that deferred articles names the ids to re-request. Absent when none of those applies.'),
643
708
  truncated: z
644
709
  .boolean()
645
710
  .optional()
646
- .describe('True when a character budget shortened at least one returned body. Absent when every returned article carries its full post-filter body. The per-article accounting is in `truncation`.'),
711
+ .describe('True when a character budget shortened at least one returned body, or withheld a whole article. Absent when every resolved article is present with its full post-filter body. The per-article body accounting is in `truncation`; the withheld ids are in `deferred`.'),
647
712
  },
648
713
  async handler(input, ctx) {
649
714
  ctx.log.info('Executing pubmed_fetch_fulltext', {
@@ -659,6 +724,17 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
659
724
  // for, so we can skip them when building `unavailable[]`.
660
725
  const chainByInput = new Map();
661
726
  const recoveredIds = new Set();
727
+ // Per-input-id set of tiers this deployment has not configured AND that
728
+ // could have served that id — the `unqueriedTiers` array on unavailable
729
+ // entries. Insertion order is chain order, so the array reads in the order
730
+ // the tiers would have run. A tier skipped as inapplicable is never marked;
731
+ // that is a settled answer, not an incomplete search. (#110)
732
+ const unqueriedByInput = new Map();
733
+ const markUnqueried = (inputId, tier) => {
734
+ const tiers = unqueriedByInput.get(inputId) ?? new Set();
735
+ tiers.add(tier);
736
+ unqueriedByInput.set(inputId, tiers);
737
+ };
662
738
  // Back-map from a converter-resolved prefixed PMCID to the input id that
663
739
  // seeded it — a PMID for `pmids` input, a DOI for `dois` input — so the PMC
664
740
  // and EPMC stages attribute recoveries and misses to the original input id.
@@ -679,6 +755,11 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
679
755
  // requested or nothing exceeded it (#81).
680
756
  const truncatedArticles = [];
681
757
  let omittedSections = 0;
758
+ // The input id each returned article was requested under, so the
759
+ // whole-response budget can hand deferred articles back as identifiers the
760
+ // caller can re-submit rather than whatever id the article happens to
761
+ // carry — a `pmids` request recovers articles keyed by PMCID. (#100)
762
+ const inputIdByArticle = new Map();
682
763
  const budget = {
683
764
  overflowMode: input.overflowMode,
684
765
  ...(input.maxCharacters !== undefined && { maxCharacters: input.maxCharacters }),
@@ -843,7 +924,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
843
924
  ...budgeted.truncation,
844
925
  });
845
926
  }
846
- parsed.push({ source: 'pmc', viaSource: 'pmc', ...budgeted.article });
927
+ const article = {
928
+ source: 'pmc',
929
+ viaSource: 'pmc',
930
+ ...budgeted.article,
931
+ };
932
+ parsed.push(article);
933
+ if (article.pmcId) {
934
+ inputIdByArticle.set(article, pmcidToInputId.get(article.pmcId) ?? article.pmcId);
935
+ }
847
936
  }
848
937
  pmcArticles = parsed;
849
938
  const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId).filter((id) => !!id));
@@ -905,10 +994,14 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
905
994
  sectionFilterMisses: [],
906
995
  truncatedArticles: [],
907
996
  omittedSections: 0,
997
+ articleInputIds: new Map(),
908
998
  };
909
999
  pmcArticles = pmcArticles.concat(epmcOutcomes.articles);
910
1000
  truncatedArticles.push(...epmcOutcomes.truncatedArticles);
911
1001
  omittedSections += epmcOutcomes.omittedSections;
1002
+ for (const [article, candidateId] of epmcOutcomes.articleInputIds) {
1003
+ inputIdByArticle.set(article, pmcidToInputId.get(candidateId) ?? candidateId);
1004
+ }
912
1005
  // Fold EPMC outcomes into each id's chain. EPMC-served articles count as
913
1006
  // recovered, so their ids are added to `recoveredIds` here.
914
1007
  if (!epmc) {
@@ -917,14 +1010,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
917
1010
  outcome: 'not-attempted',
918
1011
  detail: 'EUROPEPMC_ENABLED=false',
919
1012
  };
1013
+ // EPMC searches by PMID, PMCID, and DOI alike, so it could have served
1014
+ // every candidate that reached this stage — no applicability test.
1015
+ const skipEpmc = (inputId) => {
1016
+ chainByInput.get(inputId)?.push(epmcDisabledEntry);
1017
+ markUnqueried(inputId, 'europepmc');
1018
+ };
920
1019
  for (const c of pmidFallbackCandidates)
921
- chainByInput.get(c.pmid)?.push(epmcDisabledEntry);
1020
+ skipEpmc(c.pmid);
922
1021
  for (const c of pmcidFallbackCandidates) {
923
1022
  const prefixed = withPmcPrefix(c.pmcid);
924
- chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push(epmcDisabledEntry);
1023
+ skipEpmc(pmcidToInputId.get(prefixed) ?? prefixed);
925
1024
  }
926
1025
  for (const c of doiCandidates)
927
- chainByInput.get(c.doi)?.push(epmcDisabledEntry);
1026
+ skipEpmc(c.doi);
928
1027
  }
929
1028
  else {
930
1029
  const foldEpmcOutcome = (inputId, outcome) => {
@@ -957,13 +1056,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
957
1056
  // Converter, which returns DOIs for PMC-indexed records. (#88)
958
1057
  if (pmcidFallbackCandidates.length > 0) {
959
1058
  if (!unpaywall) {
1059
+ // The PMCID → DOI lookup below only runs when Unpaywall is configured,
1060
+ // so a candidate arrives here with a DOI only if the EPMC stage handed
1061
+ // one over. An absent DOI therefore means "never looked up", not "this
1062
+ // record has none" — the tier stays a genuine unknown and is marked.
960
1063
  for (const c of pmcidFallbackCandidates) {
961
1064
  const prefixed = withPmcPrefix(c.pmcid);
962
- chainByInput.get(pmcidToInputId.get(prefixed) ?? prefixed)?.push({
1065
+ const inputId = pmcidToInputId.get(prefixed) ?? prefixed;
1066
+ chainByInput.get(inputId)?.push({
963
1067
  tier: 'unpaywall',
964
1068
  outcome: 'not-attempted',
965
1069
  detail: 'UNPAYWALL_EMAIL is not set',
966
1070
  });
1071
+ markUnqueried(inputId, 'unpaywall');
967
1072
  }
968
1073
  }
969
1074
  else {
@@ -1009,6 +1114,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1009
1114
  const inputId = pmcidToInputId.get(pmcId) ?? pmcId;
1010
1115
  if ('article' in result) {
1011
1116
  fallbackArticles.push(result.article);
1117
+ inputIdByArticle.set(result.article, inputId);
1012
1118
  if (result.truncation)
1013
1119
  truncatedArticles.push(result.truncation);
1014
1120
  recoveredIds.add(inputId);
@@ -1047,12 +1153,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1047
1153
  }
1048
1154
  }
1049
1155
  if (!unpaywall) {
1156
+ // `fetchPubmedDois` has already run, so the DOI state is settled here.
1157
+ // A candidate with no DOI could not have reached Unpaywall configured
1158
+ // or not — that is `no-doi`, a real answer, not an incomplete search.
1050
1159
  for (const c of pmidFallbackCandidates) {
1160
+ if (!c.doi) {
1161
+ chainByInput.get(c.pmid)?.push({ tier: 'unpaywall', outcome: 'no-doi' });
1162
+ continue;
1163
+ }
1051
1164
  chainByInput.get(c.pmid)?.push({
1052
1165
  tier: 'unpaywall',
1053
1166
  outcome: 'not-attempted',
1054
1167
  detail: 'UNPAYWALL_EMAIL is not set',
1055
1168
  });
1169
+ markUnqueried(c.pmid, 'unpaywall');
1056
1170
  }
1057
1171
  }
1058
1172
  else {
@@ -1065,6 +1179,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1065
1179
  for (const { candidate, result } of outcomes) {
1066
1180
  if ('article' in result) {
1067
1181
  fallbackArticles.push(result.article);
1182
+ inputIdByArticle.set(result.article, candidate.pmid);
1068
1183
  if (result.truncation)
1069
1184
  truncatedArticles.push(result.truncation);
1070
1185
  recoveredIds.add(candidate.pmid);
@@ -1082,12 +1197,15 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1082
1197
  }
1083
1198
  if (doiCandidates.length > 0) {
1084
1199
  if (!unpaywall) {
1200
+ // Every candidate on this branch is a DOI, so Unpaywall applies to all
1201
+ // of them.
1085
1202
  for (const c of doiCandidates) {
1086
1203
  chainByInput.get(c.doi)?.push({
1087
1204
  tier: 'unpaywall',
1088
1205
  outcome: 'not-attempted',
1089
1206
  detail: 'UNPAYWALL_EMAIL is not set',
1090
1207
  });
1208
+ markUnqueried(c.doi, 'unpaywall');
1091
1209
  }
1092
1210
  }
1093
1211
  else {
@@ -1100,6 +1218,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1100
1218
  for (const { doi, result } of outcomes) {
1101
1219
  if ('article' in result) {
1102
1220
  fallbackArticles.push(result.article);
1221
+ inputIdByArticle.set(result.article, doi);
1103
1222
  if (result.truncation)
1104
1223
  truncatedArticles.push(result.truncation);
1105
1224
  recoveredIds.add(doi);
@@ -1120,14 +1239,50 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1120
1239
  for (const [id, chain] of chainByInput) {
1121
1240
  if (recoveredIds.has(id))
1122
1241
  continue;
1242
+ const unqueried = unqueriedByInput.get(id);
1123
1243
  unavailable.push({
1124
1244
  id,
1125
1245
  idType,
1126
1246
  reason: reasonFromChain(chain),
1127
1247
  triedTiers: chain,
1248
+ ...(unqueried?.size && { unqueriedTiers: [...unqueried] }),
1128
1249
  });
1129
1250
  }
1130
- const articles = [...pmcArticles, ...fallbackArticles];
1251
+ // Whole-response budget: fill with complete records in response order and
1252
+ // hand the rest back as identifiers the caller can re-submit. One ledger for
1253
+ // every tier — a PMC-served article and an Unpaywall-served one spend the
1254
+ // same characters. Without `maxResponseCharacters` nothing is measured and
1255
+ // the response is exactly what it was before the budget existed. (#100)
1256
+ const resolved = [...pmcArticles, ...fallbackArticles];
1257
+ const ceiling = input.maxResponseCharacters;
1258
+ const fit = ceiling === undefined ? undefined : fitWholeItems(resolved, ceiling);
1259
+ const articles = fit?.kept ?? resolved;
1260
+ const nextDeferredCharacters = fit?.nextDeferredCharacters;
1261
+ const deferred = ceiling !== undefined && nextDeferredCharacters !== undefined && fit
1262
+ ? {
1263
+ maxResponseCharacters: ceiling,
1264
+ returnedCharacters: fit.keptCharacters,
1265
+ deferredCount: fit.deferred.length,
1266
+ idType,
1267
+ // Every recovery site records the input id; `articleDisplayId` is
1268
+ // the total-function fallback, not an expected path.
1269
+ ids: fit.deferred.map((a) => inputIdByArticle.get(a) ?? articleDisplayId(a)),
1270
+ nextDeferredCharacters,
1271
+ }
1272
+ : undefined;
1273
+ // A deferred article takes its body accounting out of the response with it —
1274
+ // those counts describe text the caller never received.
1275
+ if (fit) {
1276
+ for (const article of fit.deferred) {
1277
+ const id = articleDisplayId(article);
1278
+ const index = truncatedArticles.findIndex((t) => t.id === id);
1279
+ if (index === -1)
1280
+ continue;
1281
+ const [dropped] = truncatedArticles.splice(index, 1);
1282
+ if (dropped)
1283
+ omittedSections -= countOmittedSections(dropped, input.overflowMode);
1284
+ }
1285
+ }
1131
1286
  ctx.log.info('pubmed_fetch_fulltext completed', {
1132
1287
  requested: (input.pmids ?? input.pmcids ?? input.dois)?.length ?? 0,
1133
1288
  returned: articles.length,
@@ -1135,6 +1290,7 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1135
1290
  epmcHits: pmcArticles.filter((a) => a.viaSource === 'europepmc').length,
1136
1291
  unpaywallHits: fallbackArticles.length,
1137
1292
  unavailable: unavailable.length,
1293
+ ...(deferred && { deferred: deferred.deferredCount }),
1138
1294
  });
1139
1295
  // Rolled up only when the budget actually removed characters, so an
1140
1296
  // under-budget request returns exactly what it did before the budget
@@ -1165,6 +1321,10 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1165
1321
  notices.push(buildTruncationNotice(truncation));
1166
1322
  ctx.enrich({ truncated: true });
1167
1323
  }
1324
+ if (deferred) {
1325
+ notices.push(buildDeferralNotice(deferred));
1326
+ ctx.enrich({ truncated: true });
1327
+ }
1168
1328
  if (notices.length > 0)
1169
1329
  ctx.enrich.notice(notices.join(' '));
1170
1330
  return {
@@ -1172,14 +1332,20 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1172
1332
  totalReturned: articles.length,
1173
1333
  ...(unavailable.length > 0 && { unavailable }),
1174
1334
  ...(truncation && { truncation }),
1335
+ ...(deferred && { deferred }),
1175
1336
  };
1176
1337
  },
1177
1338
  format: (result) => {
1178
1339
  const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
1179
1340
  if (result.unavailable?.length) {
1180
1341
  lines.push(`\n**Unavailable (${result.unavailable.length}):**`);
1342
+ let anyUnqueried = false;
1181
1343
  for (const u of result.unavailable) {
1182
1344
  lines.push(`- [${u.idType}] ${u.id} — ${u.reason}`);
1345
+ if (u.unqueriedTiers?.length) {
1346
+ anyUnqueried = true;
1347
+ lines.push(` Not queried: ${formatUnqueriedTiers(u.unqueriedTiers, u.triedTiers)}`);
1348
+ }
1183
1349
  const chain = u.triedTiers
1184
1350
  .map((t) => {
1185
1351
  const detail = t.detail ? sanitizeChainDetail(t.detail) : undefined;
@@ -1189,8 +1355,19 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
1189
1355
  if (chain)
1190
1356
  lines.push(` chain: ${chain}`);
1191
1357
  }
1358
+ // One explanation for the whole list — repeating it per entry buries the
1359
+ // ids it qualifies.
1360
+ if (anyUnqueried) {
1361
+ lines.push(`\n> Tiers marked "Not queried" were skipped because this deployment has not configured them, so those searches are incomplete — a deployment with those tiers configured may still resolve the affected ids.`);
1362
+ }
1192
1363
  }
1193
- if (result.totalReturned === 0) {
1364
+ if (result.deferred) {
1365
+ const d = result.deferred;
1366
+ lines.push(`\n**Deferred by the response budget:** ${d.deferredCount} article(s) — ${d.returnedCharacters} of ${d.maxResponseCharacters} budgeted characters returned; next deferred article ${d.nextDeferredCharacters} characters`, `Re-call \`pubmed_fetch_fulltext\` with these ${d.idType} ids as \`${d.idType}s\`: ${d.ids.join(', ')}`);
1367
+ }
1368
+ // An empty response under a budget is a deferral, not an absence — the
1369
+ // articles resolved and the ids above retrieve them.
1370
+ if (result.totalReturned === 0 && !result.deferred) {
1194
1371
  lines.push(`\n> No full-text articles returned. Articles must be open-access and indexed in PMC, Europe PMC, or recoverable via Unpaywall to retrieve full text. For metadata and abstracts only, use \`pubmed_fetch_articles\`.`);
1195
1372
  }
1196
1373
  if (result.truncation)
@@ -1272,6 +1449,7 @@ async function runEpmcStage(epmc, args) {
1272
1449
  Promise.all(args.doiCandidates.map(fetchForDoi)),
1273
1450
  ]);
1274
1451
  const articles = [];
1452
+ const articleInputIds = new Map();
1275
1453
  const remainingPmid = [];
1276
1454
  const remainingPmcid = [];
1277
1455
  const remainingDoi = [];
@@ -1281,8 +1459,9 @@ async function runEpmcStage(epmc, args) {
1281
1459
  const sectionFilterMisses = [];
1282
1460
  const truncatedArticles = [];
1283
1461
  let omittedSections = 0;
1284
- const collectHit = (run) => {
1462
+ const collectHit = (candidateId, run) => {
1285
1463
  articles.push(run.article);
1464
+ articleInputIds.set(run.article, candidateId);
1286
1465
  if (run.sectionFilterMiss)
1287
1466
  sectionFilterMisses.push(articleDisplayId(run.article));
1288
1467
  if (run.truncation)
@@ -1292,26 +1471,27 @@ async function runEpmcStage(epmc, args) {
1292
1471
  for (const run of pmidResults) {
1293
1472
  pmidOutcomes.set(run.c.pmid, run.outcome);
1294
1473
  if (run.article)
1295
- collectHit({ ...run, article: run.article });
1474
+ collectHit(run.c.pmid, { ...run, article: run.article });
1296
1475
  else
1297
1476
  remainingPmid.push(run.c);
1298
1477
  }
1299
1478
  for (const run of pmcidResults) {
1300
1479
  pmcidOutcomes.set(run.c.normalized, run.outcome);
1301
1480
  if (run.article)
1302
- collectHit({ ...run, article: run.article });
1481
+ collectHit(run.c.normalized, { ...run, article: run.article });
1303
1482
  else
1304
1483
  remainingPmcid.push(run.doi && !run.c.c.doi ? { ...run.c.c, doi: run.doi } : run.c.c);
1305
1484
  }
1306
1485
  for (const run of doiResults) {
1307
1486
  doiOutcomes.set(run.c.doi, run.outcome);
1308
1487
  if (run.article)
1309
- collectHit({ ...run, article: run.article });
1488
+ collectHit(run.c.doi, { ...run, article: run.article });
1310
1489
  else
1311
1490
  remainingDoi.push(run.c);
1312
1491
  }
1313
1492
  return {
1314
1493
  articles,
1494
+ articleInputIds,
1315
1495
  remainingPmid,
1316
1496
  remainingPmcid,
1317
1497
  remainingDoi,
@@ -1614,11 +1794,15 @@ function unpaywallReasonToTierOutcome(reason) {
1614
1794
  }
1615
1795
  }
1616
1796
  /**
1617
- * Derive the terminal `reason` shown on the unavailable entry from its chain.
1618
- * Skips `not-attempted` entries when summarizing those record config state,
1619
- * not content state, so they make a misleading `reason` when an earlier tier
1620
- * produced a real signal (`pmc:miss`, `unpaywall:no-oa`, etc.). Only when every
1621
- * tier was skipped does `reason` fall back to `no-pmc-fallback-disabled`.
1797
+ * Derive the `reason` shown on the unavailable entry from its chain: the most
1798
+ * specific content signal the last tier that actually answered reported.
1799
+ *
1800
+ * Skipped tiers are deliberately not folded in here. A configuration note in
1801
+ * place of the content signal would erase the one specific thing the chain
1802
+ * learned; the incompleteness is reported alongside it, on `unqueriedTiers`,
1803
+ * where it adds to the answer instead of replacing it. A chain where no tier
1804
+ * was attempted at all has no signal to report and stays
1805
+ * `no-pmc-fallback-disabled`.
1622
1806
  */
1623
1807
  function reasonFromChain(chain) {
1624
1808
  let lastSignal;
@@ -1628,6 +1812,13 @@ function reasonFromChain(chain) {
1628
1812
  }
1629
1813
  if (!lastSignal)
1630
1814
  return 'no-pmc-fallback-disabled';
1815
+ // Unpaywall answering `no-doi` for an id the content tiers never found adds
1816
+ // nothing: record absence is the specific signal, so it stays `not-found`.
1817
+ if (lastSignal.tier === 'unpaywall' && lastSignal.outcome === 'no-doi') {
1818
+ const lastContentSignal = chain.findLast((t) => t.tier !== 'unpaywall' && t.outcome !== 'not-attempted');
1819
+ if (lastContentSignal?.outcome === 'miss')
1820
+ return 'not-found';
1821
+ }
1631
1822
  const key = `${lastSignal.tier}:${lastSignal.outcome}`;
1632
1823
  switch (key) {
1633
1824
  case 'pmc:miss':
@@ -1655,6 +1846,26 @@ function reasonFromChain(chain) {
1655
1846
  }
1656
1847
  }
1657
1848
  // ─── format() helpers ────────────────────────────────────────────────────────
1849
+ /** Human-readable tier names for the unqueried-tier line. */
1850
+ const UNQUERIED_TIER_LABELS = {
1851
+ europepmc: 'Europe PMC',
1852
+ unpaywall: 'Unpaywall',
1853
+ };
1854
+ /**
1855
+ * Name each unqueried tier with the reason its chain entry gave for skipping it,
1856
+ * so a `content[]` reader learns which setting is missing without decoding the
1857
+ * chain line. The detail goes through the same sanitizer the chain does — it is
1858
+ * the same upstream string. (#110)
1859
+ */
1860
+ function formatUnqueriedTiers(tiers, chain) {
1861
+ return tiers
1862
+ .map((tier) => {
1863
+ const detail = chain.find((t) => t.tier === tier && t.outcome === 'not-attempted')?.detail;
1864
+ const label = UNQUERIED_TIER_LABELS[tier];
1865
+ return detail ? `${label} (${sanitizeChainDetail(detail)})` : label;
1866
+ })
1867
+ .join(', ');
1868
+ }
1658
1869
  /**
1659
1870
  * Render the response-level character accounting. Every field is rendered
1660
1871
  * unconditionally so `content[]` readers see the same budget detail