@bbradar/mcp 0.3.0 → 0.3.1

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.
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { requiresActivityQuery } from "./activityContract.js";
2
+ import { requiresActivityQuery, validateActivityCatalogInput } from "./activityContract.js";
3
3
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { z } from "zod";
5
5
  import { BBRadarApiError } from "./bbradarClient.js";
@@ -7,7 +7,7 @@ import { NumericCursorStore } from "./cursorStore.js";
7
7
  import { stableStringify, stripUndefined } from "./json.js";
8
8
  import { FixedWindowRateLimiter } from "./rateLimit.js";
9
9
  import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
10
- const SERVER_VERSION = "0.3.0";
10
+ const SERVER_VERSION = "0.3.1";
11
11
  const MAX_PROGRAMS_PER_SEARCH = 100;
12
12
  const MAX_TARGET_EXPORT = 500;
13
13
  const MAX_RECENT_CHANGES = 100;
@@ -46,7 +46,7 @@ const MAX_IN_MEMORY_FEED_CURSORS = 500;
46
46
  const MAX_RESOLVE_SEARCH_QUERIES = 4;
47
47
  const FAST_RESOLVE_SEARCH_QUERIES = 2;
48
48
  const MAX_DIRECT_HANDLE_RESOLUTION_REQUESTS = 96;
49
- const SDK_VERSION = "1.29.0";
49
+ const SDK_VERSION = "1.31.0";
50
50
  const CORE_TOOL_NAMES = new Set([
51
51
  "search_programs",
52
52
  "get_program",
@@ -257,11 +257,11 @@ const stableSnapshotSchema = z
257
257
  .string()
258
258
  .trim()
259
259
  .min(1)
260
- .max(1_024)
260
+ .max(32, "Catalog snapshot tokens have at most 32 characters. Use 'start', then meta.snapshot; Activity snapshot IDs belong only to Activity and payout tools.")
261
261
  .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
262
262
  message: "Snapshot tokens cannot contain control characters."
263
263
  })
264
- .describe("Optional stable-page token. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Snapshot creation is consistency-first and can be slower; omit it for the fastest live result.");
264
+ .describe("Optional catalog/feed snapshot token, at most 32 characters. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Do not pass get_activity_snapshot's snapshot_id here. Snapshot creation can be slower; omit it for the fastest live result.");
265
265
  const opaqueCursorSchema = z.string().min(1).max(4096).describe("Opaque five-minute cursor. Reuse with identical filters and page size; restart the query on expiry.");
266
266
  const analyticsDateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use an inclusive UTC YYYY-MM-DD date.");
267
267
  const proLimitSchema = z.number().int().min(1).max(100);
@@ -309,7 +309,7 @@ const activityEventPeriodSchema = () => z.enum(["7d", "30d", "90d"]);
309
309
  const activityCatalogNumberSchema = () => z.number().finite().min(0).optional();
310
310
  const activityCatalogInput = {
311
311
  program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
312
- payout_metric: z.enum(["payout_total", "payout_increase", "payout_native_90d"]).optional(),
312
+ payout_metric: z.enum(["payout_total", "payout_increase", "payout_native_90d"]).optional().describe("payout_total requires all_time; payout_increase is a tracked counter increase in 24h/7d/30d/90d; payout_native_90d requires 90d and is provider-reported. Omit to select the period's default metric."),
313
313
  payout_period: activityCountPeriodSchema().optional(),
314
314
  payout_precision: z.enum(["exact", "proven_bounds"]).optional(),
315
315
  payout_min: activityCatalogNumberSchema(), payout_max: activityCatalogNumberSchema(),
@@ -322,7 +322,7 @@ const activityCatalogInput = {
322
322
  reward_change: z.enum(["increased", "decreased"]).optional(), reward_change_min_pct: activityCatalogNumberSchema(),
323
323
  updates_period: activityEventPeriodSchema().optional(),
324
324
  active_days_min: activityCatalogNumberSchema(), changes_min: activityCatalogNumberSchema(),
325
- change_category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
325
+ change_category: z.enum(["scope", "rewards", "rules", "program"]).optional().describe("Update-event category; requires its own updates_period. scope_period with scope_added_min already filters scope additions and does not need this field."),
326
326
  reports_period: activityCountPeriodSchema().optional(),
327
327
  reports_min: activityCatalogNumberSchema(), reports_max: activityCatalogNumberSchema(),
328
328
  reports_change: z.enum(["rising", "falling"]).optional(), reports_change_min_pct: activityCatalogNumberSchema(),
@@ -336,7 +336,7 @@ const activityCatalogInput = {
336
336
  activity_labels: z.array(z.string().min(1).max(64)).max(10).optional(),
337
337
  sort_by: activityCatalogSortSchema.optional(), sort_direction: z.enum(["asc", "desc"]).optional()
338
338
  };
339
- const activityCatalogGuidance = "Give each Activity signal its period (e.g. payout_period=30d with payout_positive=true; scope_period with scope_added_min) and use sort_by/sort_direction combinations enabled in get_activity_capabilities; default is updated descending. Tracked no_increase/lower require 30d. Nothing falls back to another period or precision.";
339
+ const activityCatalogGuidance = "Give each Activity signal its own period. payout_total requires all_time; payout_increase uses 24h/7d/30d/90d; payout_native_90d requires 90d. change_category requires updates_period even when scope_period is present; scope_period with scope_added_min needs no change_category. Use enabled sort_by/sort_direction combinations; default is updated descending. Tracked no_increase/lower require 30d. Catalog snapshot is 'start' or meta.snapshot, never an Activity snapshot_id. An empty result does not authorize relaxing the user's thresholds or restricting platforms to a recent-feed sample.";
340
340
  const freeSubmissionOnlySchema = z.boolean().default(false).describe("Only programs with no detected submission fee or deposit.");
341
341
  const programIdSchema = z
342
342
  .string()
@@ -444,7 +444,13 @@ function createProfiledToolRegistrar(server, profile, client) {
444
444
  const extra = args[args.length - 1];
445
445
  return client.withRequestContext(extra.signal, () => callback(...args), readObject(args[0])?.snapshot === "start");
446
446
  });
447
- const registeredTool = server.registerTool(name, toolConfig, scopedCallback);
447
+ // Raw SDK shapes otherwise strip unknown arguments. Every advertised
448
+ // filter must be applied or rejected, including on the older shortcuts.
449
+ const inputSchema = toolConfig.inputSchema;
450
+ const strictConfig = inputSchema && !(inputSchema instanceof z.ZodType)
451
+ ? { ...toolConfig, inputSchema: z.object(inputSchema).strict() }
452
+ : toolConfig;
453
+ const registeredTool = server.registerTool(name, strictConfig, scopedCallback);
448
454
  const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
449
455
  if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
450
456
  (requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
@@ -462,7 +468,8 @@ export function createBbradarServer(client, config) {
462
468
  "Use find_hunt_targets once for requests combining good rewards, broad scope, target quality, and recent target additions. Use find_repository_hunt_targets instead when Git or repository activity is explicitly required.",
463
469
  "Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
464
470
  "For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities, then get_activity_rankings with the matching tab for one ranking. For several conditions at once (e.g. recent payouts plus new scope, Web2-only, low competition, no submission fee) use one find_hunt_targets or find_programs call with Activity inputs and local filters together. Keep periods explicit; never intersect separately paginated rankings.",
465
- "Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
471
+ "Activity data is published in versions. get_activity_snapshot's snapshot_id binds only Activity capabilities, Activity rankings and program payouts. Catalog/feed tools use snapshot=start then meta.snapshot instead; do not send an Activity snapshot_id to find_programs. Never mix rows from different versions.",
472
+ "Preserve the user's thresholds, periods and platform scope when discovery times out or returns no matches. A sampled recent-change feed does not enumerate all supported platforms. Inspect partial_success, errors, ranking_scope and scan before claiming complete coverage.",
466
473
  "For semantic changes use get_history_changes or get_program_history, then get_history_change_detail only when before/after items are needed. For exact activity use get_program_activity; for ranked activity use get_analytics_program_activity. Use daily analytics tools directly for date series.",
467
474
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
468
475
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -474,7 +481,8 @@ export function createBbradarServer(client, config) {
474
481
  "Do not repeat program discovery with alternate sorts or fan out get_program_targets across a shortlist; find_hunt_targets already includes exact bounded samples and recent target additions.",
475
482
  "Use advanced tools only after the user's goal is clear or the guide points to them.",
476
483
  "For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities. Use get_activity_rankings (or its per-view alias) for one ranking, or one find_hunt_targets/find_programs call combining Activity inputs with Web2, scope, competition and fee filters. Keep server order and explicit periods; never re-rank a sampled catalog or intersect separate pages.",
477
- "Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
484
+ "Activity data is published in versions. get_activity_snapshot's snapshot_id binds only Activity capabilities, Activity rankings and program payouts. Catalog/feed tools use snapshot=start then meta.snapshot instead; do not send an Activity snapshot_id to find_programs. Never mix rows from different versions.",
485
+ "Preserve the user's thresholds, periods and platform scope when discovery times out or returns no matches. A sampled recent-change feed does not enumerate all supported platforms. Inspect partial_success, errors, ranking_scope and scan before claiming complete coverage.",
478
486
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
479
487
  "Default to bounty-eligible in-scope data. Include out-of-scope, ineligible, VDP, disclosure-only, or no-bounty data only when the user explicitly asks.",
480
488
  "Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
@@ -560,7 +568,7 @@ export function createBbradarServer(client, config) {
560
568
  }));
561
569
  registerTool("get_activity_snapshot", {
562
570
  title: "Get Current Activity Version",
563
- description: "Read only the identity of the currently published Activity version: snapshot_id, frozen as_of cutoff, expires_at, policy_version and suggested poll_seconds. Use it to detect newer Activity data and pass snapshot_id as snapshot to bind capabilities, first pages and payouts to one version. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
571
+ description: "Read only the identity of the currently published Activity version: snapshot_id, frozen as_of cutoff, expires_at, policy_version and suggested poll_seconds. Pass snapshot_id as snapshot only to get_activity_capabilities, get_activity_rankings and get_program_payouts. Catalog/feed tools such as find_programs use snapshot=start then meta.snapshot instead. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
564
572
  inputSchema: z.object({}).strict(),
565
573
  outputSchema: { ...apiEnvelopeOutputShape(), snapshot_id: z.string().optional(), as_of: z.string().optional(), expires_at: z.string().optional(), policy_version: z.string().optional(), poll_seconds: z.number().optional() },
566
574
  annotations: readOnlyAnnotations
@@ -918,6 +926,7 @@ export function createBbradarServer(client, config) {
918
926
  program_id: programIdentifierSchema,
919
927
  search: searchTextSchema.optional(),
920
928
  target_type: filterValueSchema().optional(),
929
+ language_tags: languageTagListSchema().describe("Keep targets with any requested language tag. Applied while paging; repeat with the returned next_offset."),
921
930
  min_target_score: z.number().int().min(0).max(100).optional(),
922
931
  target_opportunity_label: targetOpportunityLabelSchema.optional(),
923
932
  competition_risk: competitionRiskSchema.optional(),
@@ -1195,7 +1204,7 @@ export function createBbradarServer(client, config) {
1195
1204
  include_ineligible: z.boolean().default(false).describe("Include ineligible."),
1196
1205
  recent_changes_page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
1197
1206
  max_results: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(10),
1198
- include_full_target_list: z.boolean().default(true).describe("Fetch the newest matching program's complete target list. Set false for the fastest cross-program discovery."),
1207
+ include_full_target_list: z.boolean().default(true).describe("Fetch up to full_target_limit targets for the newest matching program. Check full_targets_has_more and continue with get_program_targets at full_targets_next_offset. Set false for the fastest cross-program discovery."),
1199
1208
  full_target_list_mode: targetListModeSchema.default("identifiers"),
1200
1209
  full_target_limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(MAX_TARGETS_PER_PROGRAM),
1201
1210
  full_list_include_out_of_scope: z.boolean().default(false),
@@ -2502,6 +2511,7 @@ function bbradarGuide(topic, profile, client) {
2502
2511
  decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2503
2512
  (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2504
2513
  filters,
2514
+ activity_catalog: activityCatalogGuidance,
2505
2515
  advanced_tools: advancedTools,
2506
2516
  examples
2507
2517
  };
@@ -2580,7 +2590,8 @@ function coreBbradarGuide(topic, client) {
2580
2590
  ],
2581
2591
  decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2582
2592
  (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2583
- available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name))
2593
+ available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name)),
2594
+ activity_catalog: activityCatalogGuidance
2584
2595
  };
2585
2596
  }
2586
2597
  function toolAvailableForContract(client, toolName) {
@@ -3590,29 +3601,14 @@ async function runResolvedProgramAction(client, config, programId, input) {
3590
3601
  });
3591
3602
  }
3592
3603
  if (input.action === "targets") {
3593
- const api = await client.getProgramTargets(programId);
3594
- const data = readObject(api.data);
3595
- const rawTargets = readArray(data?.targets);
3596
- const sanitizedTargets = rawTargets
3597
- .map(sanitizeTarget)
3598
- .filter((target) => targetHasAllowedScope(target, {
3604
+ return getProgramTargetsPayload(client, {
3605
+ target_type: input.target_type,
3606
+ language_tags: input.language_tags,
3599
3607
  include_out_of_scope: input.include_out_of_scope,
3600
3608
  include_ineligible: input.include_ineligible,
3601
- strict_scope_filter: true
3602
- }));
3603
- const targets = sanitizedTargets.slice(0, input.max_targets);
3604
- return withApiMetadata(api, {
3605
- program_id: programId,
3606
- targets: formatTargetList(targets, input.target_list_mode),
3607
- meta: {
3608
- limit: input.max_targets,
3609
- output_mode: input.target_list_mode,
3610
- total_active_targets: rawTargets.length,
3611
- total_after_filters: sanitizedTargets.length,
3612
- returned: targets.length,
3613
- has_more: sanitizedTargets.length > targets.length
3614
- }
3615
- });
3609
+ strict_scope_filter: true,
3610
+ offset: 0, limit: input.max_targets, output_mode: input.target_list_mode
3611
+ }, programId);
3616
3612
  }
3617
3613
  return checkProgramNewTargets(client, config, {
3618
3614
  program_id: programId,
@@ -4738,30 +4734,18 @@ async function getLatestAddedTargets(client, config, input) {
4738
4734
  .map((change) => readObject(change.target))
4739
4735
  .filter((target) => target !== undefined);
4740
4736
  let fullTargets = [];
4741
- let totalActiveTargets;
4742
- let totalAfterFilters;
4737
+ let fullTargetsMeta;
4743
4738
  if (input.include_full_target_list && programId) {
4744
- const targetsApi = await client.getProgramTargets(programId);
4745
- const targetsData = readObject(targetsApi.data);
4746
- const rawTargets = readArray(targetsData?.targets);
4747
- fullTargets = rawTargets
4748
- .map(sanitizeTarget)
4749
- .filter((target) => targetHasAllowedScope(target, {
4739
+ const payload = await getProgramTargetsPayload(client, {
4750
4740
  include_out_of_scope: input.full_list_include_out_of_scope,
4751
4741
  include_ineligible: input.full_list_include_ineligible,
4752
- strict_scope_filter: false
4753
- }));
4754
- totalActiveTargets = rawTargets.length;
4755
- totalAfterFilters = fullTargets.length;
4756
- sourceRequests.push({
4757
- source: "program_targets",
4758
- program_id: programId,
4759
- request_id: targetsApi.requestId,
4760
- upstream_request_id: targetsApi.upstreamRequestId,
4761
- ...apiSourceMetadata(targetsApi)
4762
- });
4742
+ strict_scope_filter: false,
4743
+ offset: 0, limit: input.full_target_limit, output_mode: input.full_target_list_mode
4744
+ }, programId);
4745
+ fullTargets = readArray(payload.targets);
4746
+ fullTargetsMeta = readObject(payload.meta);
4747
+ sourceRequests.push(...readArray(payload.source_requests).map(readObject).filter((request) => request !== undefined));
4763
4748
  }
4764
- const limitedFullTargets = fullTargets.slice(0, input.full_target_limit);
4765
4749
  return stripUndefined({
4766
4750
  request_id: randomUUID(),
4767
4751
  source_requests: sourceRequests,
@@ -4770,17 +4754,19 @@ async function getLatestAddedTargets(client, config, input) {
4770
4754
  program,
4771
4755
  matches,
4772
4756
  new_targets: formatTargetList(latestNewTargets, input.full_target_list_mode),
4773
- full_targets: input.include_full_target_list ? formatTargetList(limitedFullTargets, input.full_target_list_mode) : undefined,
4757
+ full_targets: input.include_full_target_list ? fullTargets : undefined,
4774
4758
  meta: stripUndefined({
4775
4759
  recent_changes_scanned: changes.length,
4776
4760
  matching_changes_scanned: matchingChanges.length,
4777
4761
  matches_returned: matches.length,
4778
4762
  matches_has_more: matches.length < matchingChanges.length,
4779
4763
  new_targets_returned: formatTargetList(latestNewTargets, input.full_target_list_mode).length,
4780
- full_targets_total_active: totalActiveTargets,
4781
- full_targets_total_after_filters: totalAfterFilters,
4782
- full_targets_returned: input.include_full_target_list ? formatTargetList(limitedFullTargets, input.full_target_list_mode).length : undefined,
4783
- full_targets_has_more: input.include_full_target_list ? limitedFullTargets.length < fullTargets.length : undefined
4764
+ full_targets_total_active: fullTargetsMeta?.total_active_targets,
4765
+ full_targets_total_after_filters: fullTargetsMeta?.total_after_filters,
4766
+ full_targets_returned: input.include_full_target_list ? fullTargets.length : undefined,
4767
+ full_targets_has_more: fullTargetsMeta?.has_more,
4768
+ full_targets_next_offset: fullTargetsMeta?.next_offset,
4769
+ full_targets_possibly_incomplete: fullTargetsMeta?.possibly_incomplete
4784
4770
  })
4785
4771
  });
4786
4772
  }
@@ -4818,15 +4804,21 @@ function latestAddedProgramMatches(change, input, platformFilters) {
4818
4804
  return true;
4819
4805
  }
4820
4806
  async function getProgramTargetsPayload(client, input, programId) {
4821
- const pageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4807
+ const maxPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4822
4808
  // `eligibility=eligible` is an API-level in-scope bounty-eligibility filter,
4823
4809
  // so the common eligible-only path needs no second local scope pass.
4824
- const directOffsetPaging = !input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope);
4810
+ const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
4811
+ const directOffsetPaging = languageTags.size === 0 && (!input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope));
4812
+ // Bound direct reads to the requested prefix. Local post-filters still need
4813
+ // full pages to find sparse matches. Offsets remain filtered row offsets,
4814
+ // independent of the page size used by an earlier call.
4815
+ const pageSize = directOffsetPaging ? Math.min(maxPageSize, input.offset + input.limit) : maxPageSize;
4825
4816
  const collected = [];
4826
4817
  let matchedSeen = directOffsetPaging ? Math.floor(input.offset / pageSize) * pageSize : 0;
4827
4818
  let page = directOffsetPaging ? Math.floor(input.offset / pageSize) + 1 : 1;
4828
4819
  const firstPage = page;
4829
4820
  let api;
4821
+ const sourceRequests = [];
4830
4822
  let upstreamMeta;
4831
4823
  let upstreamHasMore = false;
4832
4824
  do {
@@ -4840,11 +4832,15 @@ async function getProgramTargetsPayload(client, input, programId) {
4840
4832
  page,
4841
4833
  page_size: pageSize
4842
4834
  });
4835
+ sourceRequests.push({ source: "program_targets", program_id: programId, page,
4836
+ request_id: api.requestId, upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
4843
4837
  const data = readObject(api.data);
4844
4838
  upstreamMeta = readObject(data?.meta);
4845
4839
  for (const target of readArray(data?.targets).map(sanitizeTarget)) {
4846
4840
  if (!targetHasAllowedScope(target, input))
4847
4841
  continue;
4842
+ if (languageTags.size > 0 && !arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags")))
4843
+ continue;
4848
4844
  if (matchedSeen >= input.offset && collected.length < input.limit)
4849
4845
  collected.push(target);
4850
4846
  matchedSeen += 1;
@@ -4862,8 +4858,10 @@ async function getProgramTargetsPayload(client, input, programId) {
4862
4858
  throw new Error("No program target response was collected.");
4863
4859
  }
4864
4860
  const targets = collected;
4861
+ const hasMore = upstreamHasMore || matchedSeen > input.offset + targets.length;
4865
4862
  return withApiMetadata(api, {
4866
4863
  program_id: programId,
4864
+ source_requests: sourceRequests,
4867
4865
  targets: formatTargetList(targets, input.output_mode),
4868
4866
  meta: {
4869
4867
  offset: input.offset,
@@ -4872,7 +4870,8 @@ async function getProgramTargetsPayload(client, input, programId) {
4872
4870
  total_active_targets: readNumber(upstreamMeta?.total) ?? matchedSeen,
4873
4871
  total_after_filters: upstreamHasMore ? undefined : matchedSeen,
4874
4872
  returned: targets.length,
4875
- has_more: upstreamHasMore || matchedSeen > input.offset + targets.length,
4873
+ has_more: hasMore,
4874
+ next_offset: hasMore ? input.offset + targets.length : undefined,
4876
4875
  upstream_has_more: upstreamHasMore,
4877
4876
  possibly_incomplete: upstreamHasMore,
4878
4877
  upstream_pages_fetched: page - firstPage,
@@ -4885,9 +4884,16 @@ async function getProgramTargetsPayload(client, input, programId) {
4885
4884
  });
4886
4885
  }
4887
4886
  async function getProgramTargetsExportFallbackPayload(client, input, programId) {
4887
+ if (input.search || input.target_type || input.min_target_score !== undefined
4888
+ || input.target_opportunity_label || input.competition_risk) {
4889
+ throw new Error("The per-program targets endpoint is unavailable. Its search, type and intelligence filters cannot be preserved by the export fallback; the requested filters were not discarded.");
4890
+ }
4888
4891
  const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, programId);
4889
- const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input));
4892
+ const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
4893
+ const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input)
4894
+ && (languageTags.size === 0 || arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags"))));
4890
4895
  const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
4896
+ const hasMore = input.offset + targets.length < sanitizedTargets.length;
4891
4897
  return stripUndefined({
4892
4898
  request_id: exportLookup.requestId,
4893
4899
  upstream_request_id: exportLookup.upstreamRequestId,
@@ -4901,10 +4907,10 @@ async function getProgramTargetsExportFallbackPayload(client, input, programId)
4901
4907
  offset: input.offset,
4902
4908
  limit: input.limit,
4903
4909
  output_mode: input.output_mode,
4904
- total_active_targets: exportLookup.rawTargets.length,
4905
- total_after_filters: sanitizedTargets.length,
4910
+ ...fallbackTargetCoverage(exportLookup, sanitizedTargets.length),
4906
4911
  returned: targets.length,
4907
- has_more: input.offset + input.limit < sanitizedTargets.length,
4912
+ has_more: hasMore || exportLookup.coverage?.possibly_incomplete === true,
4913
+ next_offset: hasMore ? input.offset + targets.length : undefined,
4908
4914
  target_source: exportLookup.source,
4909
4915
  target_list_unavailable: exportLookup.unavailable,
4910
4916
  fallback_error: exportLookup.error
@@ -4933,6 +4939,7 @@ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
4933
4939
  })),
4934
4940
  source: "program_targets",
4935
4941
  rawTargets,
4942
+ coverage: targetPageCoverage(data, rawTargets.length, rawTargets.length),
4936
4943
  sourceRequests: [
4937
4944
  {
4938
4945
  source: "program_targets",
@@ -5032,14 +5039,21 @@ function cacheTtlMsFromDiagnostics(cache) {
5032
5039
  }
5033
5040
  async function fetchProgramTargetsFromExportFallback(client, programId) {
5034
5041
  try {
5042
+ const limit = Math.min(MAX_TARGET_EXPORT, client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT));
5035
5043
  const api = await client.exportTargets({
5036
5044
  program_ids: [programId],
5037
5045
  include_out_of_scope: true,
5038
5046
  include_ineligible: true,
5039
5047
  format: "json",
5040
- limit: MAX_TARGET_EXPORT
5048
+ limit
5041
5049
  });
5042
5050
  const rawTargets = readExportTargetRows(api.data).map((row) => sanitizeTarget(readObject(row)?.target ?? row));
5051
+ const data = readObject(api.data);
5052
+ const coverage = targetPageCoverage({ meta: {
5053
+ ...readObject(data?.meta),
5054
+ // A full capped export is not proof that the inventory ends here.
5055
+ has_more: readObject(data?.meta)?.has_more === true || rawTargets.length >= limit
5056
+ } }, rawTargets.length, rawTargets.length);
5043
5057
  return {
5044
5058
  requestId: api.requestId,
5045
5059
  upstreamRequestId: api.upstreamRequestId,
@@ -5053,6 +5067,7 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
5053
5067
  })),
5054
5068
  source: "target_export",
5055
5069
  rawTargets,
5070
+ coverage,
5056
5071
  sourceRequests: [
5057
5072
  {
5058
5073
  source: "target_export",
@@ -5313,8 +5328,7 @@ async function getProgramScopeSummary(client, config, input) {
5313
5328
  program: formatProgram(lookup.program, "compact"),
5314
5329
  scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
5315
5330
  meta: {
5316
- total_active_targets: rawTargets.length,
5317
- total_after_filters: targets.length,
5331
+ ...targetPageCoverage(targetsData, rawTargets.length, targets.length),
5318
5332
  target_list_mode: input.target_list_mode,
5319
5333
  group_limit: input.group_limit,
5320
5334
  target_source: "program_targets",
@@ -5339,8 +5353,7 @@ async function getProgramScopeSummaryFromIndexFallback(client, indexFallback, in
5339
5353
  program: formatProgram(indexFallback.program, "compact"),
5340
5354
  scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
5341
5355
  meta: stripUndefined({
5342
- total_active_targets: exportLookup.rawTargets.length,
5343
- total_after_filters: targets.length,
5356
+ ...fallbackTargetCoverage(exportLookup, targets.length),
5344
5357
  target_list_mode: input.target_list_mode,
5345
5358
  group_limit: input.group_limit,
5346
5359
  target_source: exportLookup.source,
@@ -5376,8 +5389,7 @@ async function getProgramTargetBreakdown(client, config, input) {
5376
5389
  groups: readObject(scope.groups)
5377
5390
  },
5378
5391
  meta: {
5379
- total_active_targets: rawTargets.length,
5380
- total_after_filters: targets.length,
5392
+ ...targetPageCoverage(targetsData, rawTargets.length, targets.length),
5381
5393
  target_list_mode: input.target_list_mode,
5382
5394
  group_limit: input.group_limit,
5383
5395
  target_source: "program_targets",
@@ -5385,6 +5397,32 @@ async function getProgramTargetBreakdown(client, config, input) {
5385
5397
  }
5386
5398
  });
5387
5399
  }
5400
+ function targetPageCoverage(data, rows, filteredRows) {
5401
+ const meta = readObject(data?.meta);
5402
+ const total = numberField(meta, "total");
5403
+ const incomplete = meta?.has_more === true || (numberField(meta, "total_pages") ?? 1) > 1
5404
+ || (total !== undefined && total > rows);
5405
+ return stripUndefined({
5406
+ total_active_targets: total ?? (incomplete ? undefined : rows),
5407
+ total_after_filters: incomplete ? undefined : filteredRows,
5408
+ targets_scanned: rows,
5409
+ matching_targets_scanned: filteredRows,
5410
+ possibly_incomplete: incomplete,
5411
+ count_scope: incomplete ? "sampled_target_page" : "complete_target_inventory"
5412
+ });
5413
+ }
5414
+ function fallbackTargetCoverage(lookup, filteredRows) {
5415
+ const incomplete = lookup.unavailable || lookup.coverage?.possibly_incomplete === true;
5416
+ return stripUndefined({
5417
+ ...lookup.coverage,
5418
+ total_active_targets: lookup.unavailable ? undefined : lookup.coverage?.total_active_targets ?? (incomplete ? undefined : lookup.rawTargets.length),
5419
+ total_after_filters: incomplete ? undefined : filteredRows,
5420
+ targets_scanned: lookup.rawTargets.length,
5421
+ matching_targets_scanned: filteredRows,
5422
+ possibly_incomplete: incomplete,
5423
+ count_scope: lookup.unavailable ? "unavailable" : incomplete ? "sampled_target_page" : "complete_target_inventory"
5424
+ });
5425
+ }
5388
5426
  async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback, input) {
5389
5427
  const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, input.program_id);
5390
5428
  const targets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, {
@@ -5411,8 +5449,7 @@ async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback,
5411
5449
  groups: readObject(scope.groups)
5412
5450
  },
5413
5451
  meta: stripUndefined({
5414
- total_active_targets: exportLookup.rawTargets.length,
5415
- total_after_filters: targets.length,
5452
+ ...fallbackTargetCoverage(exportLookup, targets.length),
5416
5453
  target_list_mode: input.target_list_mode,
5417
5454
  group_limit: input.group_limit,
5418
5455
  target_source: exportLookup.source,
@@ -6820,6 +6857,18 @@ function clampLimit(name, value, max, warnings) {
6820
6857
  // server order, so a larger pool lets ranking_mode choose among more of them.
6821
6858
  const HUNT_SERVER_POOL = 100;
6822
6859
  async function findHuntTargets(client, config, input, activity = {}) {
6860
+ const huntStartedAt = Date.now();
6861
+ const phaseTiming = {};
6862
+ const runStage = async (phase, timeoutMs, callback) => {
6863
+ const startedAt = Date.now();
6864
+ try {
6865
+ return await client.withRequestTimeout(timeoutMs, callback);
6866
+ }
6867
+ finally {
6868
+ phaseTiming[phase] = Date.now() - startedAt;
6869
+ }
6870
+ };
6871
+ validateActivityCatalogInput(activity);
6823
6872
  const warnings = [];
6824
6873
  const errors = [];
6825
6874
  const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
@@ -6885,22 +6934,28 @@ async function findHuntTargets(client, config, input, activity = {}) {
6885
6934
  warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
6886
6935
  }
6887
6936
  const serverFiltered = hasActivityCatalogInput(activity);
6937
+ // Catalog discovery and optional changes share at most 60% of the tool
6938
+ // deadline. Keep at least 30% for exact targets and 10% for the response.
6888
6939
  const [programPayload, recentResult] = await Promise.all([
6889
- serverFiltered
6940
+ runStage("program_discovery_ms", Math.floor(config.requestTimeoutMs * 0.6), () => serverFiltered
6890
6941
  ? findFilteredServerPrograms(client, config, {
6891
6942
  ...programInput, ...activity, opportunity_levels: [], sort_by: activity.sort_by, sort_direction: activity.sort_direction,
6892
6943
  platforms: input.platforms, tags: input.tags, updated_since: input.updated_since, vdp_mode: "exclude", include_target_samples: false
6893
6944
  }, {
6894
6945
  ...programInput, platforms: [], tags: [], vdp_mode: "include", updated_since: undefined,
6895
6946
  max_pages: programRequestBudget, upstream_request_budget: programRequestBudget
6896
- }, HUNT_SERVER_POOL)
6897
- : findPrograms(client, config, programInput),
6947
+ }, HUNT_SERVER_POOL, true)
6948
+ : findPrograms(client, config, programInput, {
6949
+ allowPartialTimeout: true, excludeVdpUpstream: true
6950
+ })),
6898
6951
  recentChangesEnabled
6899
- ? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
6952
+ ? runStage("recent_changes_ms", Math.floor(config.requestTimeoutMs * 0.3), () => fetchHuntRecentChanges(client, config, input, recentChangeQueries))
6900
6953
  : Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
6901
6954
  ]);
6955
+ client.throwIfRequestAborted();
6902
6956
  const programWarnings = readArray(programPayload.warnings).filter((warning) => typeof warning === "string");
6903
6957
  warnings.push(...programWarnings);
6958
+ errors.push(...readArray(programPayload.errors).map(readObject).filter((error) => error !== undefined));
6904
6959
  errors.push(...recentResult.errors);
6905
6960
  const programBudget = readObject(programPayload.upstream_budget);
6906
6961
  const programRequestsUsed = readNumber(programBudget?.used) ?? 0;
@@ -6919,8 +6974,12 @@ async function findHuntTargets(client, config, input, activity = {}) {
6919
6974
  const finalistEntries = preliminaryRankedEntries
6920
6975
  .slice(0, sampledProgramCount)
6921
6976
  .map((entry) => entry.entry);
6922
- const targetResult = await collectHuntTargetSamples(client, finalistEntries, input);
6977
+ const targetResult = await runStage("target_sampling_ms", Math.floor(config.requestTimeoutMs * 0.9) - (Date.now() - huntStartedAt), () => collectHuntTargetSamples(client, finalistEntries, input));
6978
+ client.throwIfRequestAborted();
6923
6979
  errors.push(...targetResult.errors);
6980
+ if (errors.length > 0) {
6981
+ warnings.push("Some discovery or enrichment reads failed or reached their time budget. Returned matches preserve the requested filters; missing target samples or change evidence are unavailable, not confirmed absent. Report the incomplete coverage without narrowing platforms or relaxing thresholds.");
6982
+ }
6924
6983
  if (sampledProgramCount < Math.min(input.max_results, programEntries.length)) {
6925
6984
  warnings.push(`The upstream request budget limited the final exact target shortlist to ${sampledProgramCount} of ${Math.min(input.max_results, programEntries.length)} requested results.`);
6926
6985
  }
@@ -6934,6 +6993,10 @@ async function findHuntTargets(client, config, input, activity = {}) {
6934
6993
  }));
6935
6994
  const targetRequestsUsed = targetResult.requestsUsed;
6936
6995
  const totalRequestsUsed = programRequestsUsed + recentResult.requestsUsed + targetRequestsUsed;
6996
+ const coverageIncomplete = errors.length > 0
6997
+ || readObject(programPayload.ranking_scope)?.possibly_incomplete === true
6998
+ || readObject(programPayload.scan)?.complete === false
6999
+ || sampledProgramCount < Math.min(input.max_results, programEntries.length);
6937
7000
  return stripUndefined({
6938
7001
  request_id: randomUUID(),
6939
7002
  fetched_at: new Date().toISOString(),
@@ -6985,7 +7048,10 @@ async function findHuntTargets(client, config, input, activity = {}) {
6985
7048
  result_guidance: {
6986
7049
  final_shortlist: true,
6987
7050
  follow_up_calls_required: false,
6988
- instruction: "Use these matches as the final shortlist. Exact bounded target samples and recent additions are already included; call get_program_targets only if the user explicitly requests an exhaustive scope dump for one selected program."
7051
+ coverage_incomplete: coverageIncomplete,
7052
+ instruction: coverageIncomplete
7053
+ ? "Use the bounded matches and report warnings and incomplete coverage. Preserve the user's periods, thresholds and platform scope; do not infer supported platforms from a sampled recent-change feed."
7054
+ : "Use these matches as the final shortlist. Exact bounded target samples and recent additions are already included; call get_program_targets only if the user explicitly requests an exhaustive scope dump for one selected program."
6989
7055
  },
6990
7056
  matches,
6991
7057
  meta: {
@@ -6998,7 +7064,8 @@ async function findHuntTargets(client, config, input, activity = {}) {
6998
7064
  model_round_trips_expected: 1,
6999
7065
  parallel_recent_change_fetch: recentChangeQueries.length > 0,
7000
7066
  bulk_resource_links_suppressed: true,
7001
- partial_success: errors.length > 0
7067
+ partial_success: errors.length > 0,
7068
+ phase_timing: phaseTiming
7002
7069
  }
7003
7070
  });
7004
7071
  }
@@ -7738,6 +7805,7 @@ function hasActivityCatalogInput(input) {
7738
7805
  }
7739
7806
  /** Validates and builds one server-ordered catalog query; nothing is sent. */
7740
7807
  function serverCatalogQuery(client, input) {
7808
+ validateActivityCatalogInput(input);
7741
7809
  const levels = Array.isArray(input.opportunity_levels) ? input.opportunity_levels : [];
7742
7810
  if (levels.length > 1)
7743
7811
  throw new Error("Combined Activity queries accept at most one opportunity tier; separate tier pages cannot form a global ranking.");
@@ -7817,6 +7885,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7817
7885
  const start = cursorValue ? decodeScanCursor(cursorValue, fingerprint, pageSize)
7818
7886
  : { v: 1, p: Number(input.page ?? 1), k: 0, s: pageSize, f: fingerprint, a: undefined, t: undefined };
7819
7887
  const sourceRequests = [];
7888
+ const errors = [];
7820
7889
  let matches = [];
7821
7890
  let seen = new Set();
7822
7891
  let nextPage = start.p;
@@ -7840,11 +7909,25 @@ async function scanServerCatalog(client, config, input, local, limits) {
7840
7909
  const needed = pagesRead === 0 ? 1 : Math.ceil((limits.maxResults - matches.length) / (rate * pageSize));
7841
7910
  const batch = Math.max(1, Math.min(allowance, needed, CATALOG_SCAN_PREFETCH));
7842
7911
  const pageToken = token;
7843
- const pages = await Promise.all(Array.from({ length: batch }, (_, index) => nextPage + index)
7912
+ const batchStart = nextPage;
7913
+ const pages = await Promise.allSettled(Array.from({ length: batch }, (_, index) => batchStart + index)
7844
7914
  .map(async (page) => ({ page, api: await readServerCatalogPage(client, catalog, page, pageSize, pageToken) })));
7845
- for (const { page, api } of pages) {
7915
+ // Count all admitted reads, including a failed page or a prefetched page
7916
+ // beyond a gap; only the contiguous successful prefix enters the ranking.
7917
+ for (const result of pages) {
7918
+ const noRequest = result.status === "fulfilled" ? result.value.api.cached || result.value.api.coalesced
7919
+ : result.reason instanceof BBRadarApiError && (result.reason.cached || result.reason.coalesced);
7920
+ limits.budget.remaining -= noRequest ? 0 : 1;
7921
+ }
7922
+ for (const [index, result] of pages.entries()) {
7923
+ if (result.status === "rejected") {
7924
+ if (!limits.allowPartialTimeout || pagesRead === 0 || !(result.reason instanceof BBRadarApiError) || result.reason.status !== 408)
7925
+ throw result.reason;
7926
+ errors.push(apiOperationError(`program catalog page ${batchStart + index}`, result.reason));
7927
+ break scan;
7928
+ }
7929
+ const { page, api } = result.value;
7846
7930
  pagesRead += 1;
7847
- limits.budget.remaining -= api.cached || api.coalesced ? 0 : 1;
7848
7931
  sourceRequests.push({ source: catalog.level ? `opportunities:${catalog.level}` : "programs", page, request_id: api.requestId,
7849
7932
  upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
7850
7933
  const data = readObject(api.data);
@@ -7900,6 +7983,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7900
7983
  return {
7901
7984
  candidates: matches,
7902
7985
  sourceRequests,
7986
+ errors,
7903
7987
  scan: stripUndefined({
7904
7988
  order: "Server order; local filters only remove programs.",
7905
7989
  sort_by: input.sort_by ?? "updated",
@@ -7913,17 +7997,20 @@ async function scanServerCatalog(client, config, input, local, limits) {
7913
7997
  ? encodeScanCursor({ v: 1, p: resume.p, k: resume.k, s: pageSize, f: fingerprint, a: version, t: typeof token === "string" ? token : undefined })
7914
7998
  : undefined,
7915
7999
  activity_snapshot_id: version,
7916
- restarted_on_new_activity_version: restarted || undefined
8000
+ restarted_on_new_activity_version: restarted || undefined,
8001
+ deadline_reached: errors.length > 0 || undefined
7917
8002
  })
7918
8003
  };
7919
8004
  }
7920
8005
  /** find_programs with server Activity filters plus local filters or target samples. */
7921
- async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results) {
8006
+ async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results, allowPartialTimeout = false) {
7922
8007
  const normalized = normalizeFindProgramsInput(local);
7923
8008
  const warnings = normalized.warnings;
7924
8009
  local = normalized.input;
7925
8010
  const budget = { initial: local.upstream_request_budget, remaining: local.upstream_request_budget };
7926
- const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget });
8011
+ const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget, allowPartialTimeout });
8012
+ if (result.errors.length > 0)
8013
+ warnings.push("Program discovery reached its time budget; ranking covers only the completed server-ordered prefix.");
7927
8014
  if (result.scan.complete !== true) {
7928
8015
  warnings.push("More server pages remain; scan.next_cursor continues in the same order.");
7929
8016
  }
@@ -7936,6 +8023,7 @@ async function findFilteredServerPrograms(client, config, input, local, maxResul
7936
8023
  request_id: randomUUID(),
7937
8024
  fetched_at: new Date().toISOString(),
7938
8025
  source_requests: result.sourceRequests,
8026
+ errors: result.errors.length > 0 ? result.errors : undefined,
7939
8027
  warnings: warnings.length > 0 ? warnings : undefined,
7940
8028
  upstream_budget: { used: budget.initial - budget.remaining, remaining: budget.remaining },
7941
8029
  ranking_scope: { mode: "server_filtered", sort_by: result.scan.sort_by, sort_direction: result.scan.sort_direction, complete: result.scan.complete },
@@ -7950,10 +8038,11 @@ function programHasNoSubmissionFee(program) {
7950
8038
  const amount = numberField(cost, "amount");
7951
8039
  return amount === undefined || (amount === 0 && (numberField(cost, "maximum_amount") ?? 0) === 0);
7952
8040
  }
7953
- async function findPrograms(client, config, input) {
8041
+ async function findPrograms(client, config, input, execution = {}) {
7954
8042
  const normalized = normalizeFindProgramsInput(input);
7955
8043
  const warnings = normalized.warnings;
7956
8044
  input = normalized.input;
8045
+ const errors = [];
7957
8046
  const budget = {
7958
8047
  initial: input.upstream_request_budget,
7959
8048
  remaining: input.upstream_request_budget
@@ -7970,11 +8059,13 @@ async function findPrograms(client, config, input) {
7970
8059
  platforms: input.platforms,
7971
8060
  tags: upstreamTags,
7972
8061
  program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
8062
+ vdp_mode: execution.excludeVdpUpstream && input.vdp_mode === "exclude" ? "exclude" : undefined,
7973
8063
  updated_since: input.updated_since,
7974
8064
  opportunity_levels: input.opportunity_levels,
7975
8065
  max_pages: input.max_pages,
7976
8066
  budget,
7977
- warnings
8067
+ warnings,
8068
+ partialTimeoutErrors: execution.allowPartialTimeout ? errors : undefined
7978
8069
  });
7979
8070
  const filteredCandidates = collected.programs
7980
8071
  .map((program) => toProgramCandidate(program, config.webBaseUrl))
@@ -7998,6 +8089,7 @@ async function findPrograms(client, config, input) {
7998
8089
  source_requests: collected.sourceRequests,
7999
8090
  fetched_at: new Date().toISOString(),
8000
8091
  warnings: warnings.length > 0 ? warnings : undefined,
8092
+ errors: errors.length > 0 ? errors : undefined,
8001
8093
  filters: stripUndefined({
8002
8094
  platforms: input.platforms.length > 0 ? input.platforms : undefined,
8003
8095
  exclude_platforms: input.exclude_platforms && input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
@@ -8040,7 +8132,7 @@ async function findPrograms(client, config, input) {
8040
8132
  platform_filter: input.platforms.length > 0 ? input.platforms : "all_platforms",
8041
8133
  max_pages_scanned_per_source: input.max_pages,
8042
8134
  upstream_total_pages: collected.totalPagesBySource,
8043
- possibly_incomplete: isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
8135
+ possibly_incomplete: errors.length > 0 || isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
8044
8136
  },
8045
8137
  programs,
8046
8138
  meta: {
@@ -8445,20 +8537,39 @@ async function collectProgramSource(client, source, sourceIndex, options) {
8445
8537
  async function fetchProgramPage(client, source, sourceIndex, page, options) {
8446
8538
  const runWithSlot = options.runWithProgramPageSlot ?? runImmediately;
8447
8539
  return runWithSlot(async () => {
8540
+ const endpoint = source.kind === "programs" ? "programs" : "opportunities";
8541
+ const vdpModes = readArray(readObject(client.endpointCapability(endpoint)?.catalog_filters)?.vdp_mode);
8448
8542
  const query = toQuery({
8449
8543
  search: options.search,
8450
8544
  platforms: options.platforms,
8451
8545
  tags: options.tags,
8452
8546
  updated_since: options.updated_since,
8453
8547
  program_kind: source.kind === "programs" ? options.program_kind : undefined,
8548
+ // This removes only known VDPs; unknown classifications with positive
8549
+ // rewards still reach the local filters. Older APIs keep local exclusion.
8550
+ vdp_mode: supportsCatalogFilters(client, endpoint) && vdpModes.includes("exclude") ? options.vdp_mode : undefined,
8454
8551
  page,
8455
- page_size: MAX_PROGRAMS_PER_SEARCH
8552
+ page_size: Math.min(MAX_PROGRAMS_PER_SEARCH, client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH))
8456
8553
  });
8457
8554
  if (!tryConsumeBudget(options.budget)) {
8458
8555
  addUniqueWarning(options.warnings, "Upstream request budget was exhausted before all requested program pages could be fetched.");
8459
8556
  return undefined;
8460
8557
  }
8461
- const api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
8558
+ let api;
8559
+ try {
8560
+ api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
8561
+ }
8562
+ catch (error) {
8563
+ // Hunts can retain completed catalog pages. Ordinary discovery keeps its
8564
+ // existing failure contract, and a failed first page is never an empty hit.
8565
+ if (page === 1 || !options.partialTimeoutErrors || !(error instanceof BBRadarApiError) || error.status !== 408)
8566
+ throw error;
8567
+ if (error.cached || error.coalesced)
8568
+ options.budget.remaining += 1;
8569
+ options.partialTimeoutErrors.push(apiOperationError(`program catalog page ${page}`, error));
8570
+ addUniqueWarning(options.warnings, "Program discovery reached its time budget; ranking covers only completed catalog pages.");
8571
+ return undefined;
8572
+ }
8462
8573
  refundBudgetIfNoUpstreamRequest(options.budget, api);
8463
8574
  const data = readObject(api.data);
8464
8575
  const meta = readObject(data?.meta);
@@ -9280,6 +9391,7 @@ function compactToolText(payload, isError) {
9280
9391
  }));
9281
9392
  }
9282
9393
  const meta = readObject(payload.meta);
9394
+ const coverageIncomplete = readObject(payload.result_guidance)?.coverage_incomplete === true;
9283
9395
  const returned = numberField(meta, "returned") ??
9284
9396
  [
9285
9397
  readArray(payload.programs).length,
@@ -9291,6 +9403,12 @@ function compactToolText(payload, isError) {
9291
9403
  request_id: payload.request_id,
9292
9404
  summary: "Full tool result is available in structuredContent.",
9293
9405
  returned,
9406
+ partial_success: meta?.partial_success === true ? true : undefined,
9407
+ coverage_incomplete: coverageIncomplete ? true : undefined,
9408
+ errors: meta?.partial_success === true ? readArray(payload.errors).map((error) => {
9409
+ const value = readObject(error);
9410
+ return stripUndefined({ operation: value?.operation, status: value?.status });
9411
+ }) : undefined,
9294
9412
  mcp_timing: payload.mcp_timing
9295
9413
  }));
9296
9414
  }