@zackbart/connecta 0.41.0 → 0.42.0

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -4,19 +4,21 @@ import { utf8Length } from "./bytes.js";
4
4
  import { catalogOwnerPartition } from "./catalog-policy.js";
5
5
  import { registerAuthMethod } from "./auth/native.js";
6
6
  import { META_ROUTES, routeToolInPlace } from "./agent-routes.js";
7
+ import { readProgramRefuses } from "./tool-safety.js";
7
8
  import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
8
- import { carryCatalogFreshness, catalogIsFresh, catalogGenerationFor } from "./catalog-freshness.js";
9
+ import { carryCatalogFreshness, catalogGenerationFor, catalogIsFresh, catalogStaleness, markCatalogStale, } from "./catalog-freshness.js";
9
10
  import { retainedCatalogIdentity } from "./catalog-cache.js";
10
11
  import { cloneTool } from "./tool-safety.js";
11
12
  import { hasControlCharacters } from "./tool-name.js";
12
- import { Effect, Result } from "effect";
13
- import { prepareLexicalCatalog, carryLexicalCatalog, compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
13
+ import { Effect, Result, Semaphore } from "effect";
14
+ import { prepareLexicalCatalog, carryLexicalCatalog, compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, discoverySummary, vocabularyScore, typescriptSignature, } from "./catalog.js";
14
15
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
15
16
  import { boundedEchoText, classifyCallError, ConnectorCallError, framingError } from "./errors.js";
16
17
  import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName } from "./skills.js";
17
- import { runEdge, withDeadlineEffect } from "./runtime/run.js";
18
+ import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
18
19
  import { registryProgram } from "./runtime/registry.js";
19
20
  import { SharedRead } from "./runtime/shared-read.js";
21
+ import { closeScope } from "./runtime/connector-scope.js";
20
22
  import { duringViewEffect } from "./runtime/call.js";
21
23
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, UNSCOPED_DISCOVERY_BUDGET_MS, UNSCOPED_PROBE_TIMEOUT_MS, } from "./timeout.js";
22
24
  import { claimDiscoveryProbe, readDiscoveryHealth, settleDiscoveryHealth, } from "./discovery-health.js";
@@ -24,6 +26,27 @@ import { keepErrorName } from "./redaction-identity.js";
24
26
  import { operationVocabularyMatches, searchSynonyms } from "./operation-vocabulary.js";
25
27
  /** The slice of an unscoped search's budget kept for recording what its probes saw. */
26
28
  const SETTLE_RESERVE_MS = 500;
29
+ /** Cached reads running at once in one discovery. Storage only, so they never wait for a probe slot. */
30
+ const CATALOG_PEEK_CONCURRENCY = 16;
31
+ /** The longest a stale catalog's read holds up a finished probe, and the least any peek gets. */
32
+ const STALE_READ_TIMEOUT_MS = 1_000;
33
+ /**
34
+ * One deferred refresh's whole tail, from being scheduled to the end of its
35
+ * cleanup: queueing, claim, listing, settlement and close. Inside the 30 seconds
36
+ * Workers keeps `waitUntil` alive.
37
+ */
38
+ const CATALOG_REFRESH_TIMEOUT_MS = 20_000;
39
+ /** The end of that tail no listing may use: the record's settlement, then connector cleanup. */
40
+ const REFRESH_SETTLE_MS = 1_000;
41
+ const REFRESH_CLOSE_MS = 2_000;
42
+ /**
43
+ * Failures that say the connector did not answer, so its last complete catalog
44
+ * can stand in for the listing. Auth and permission failures, refusals and
45
+ * malformed catalogs are answers, and stay failures.
46
+ */
47
+ const STALE_LISTING_CODES = new Set(["timeout", "unavailable", "rate_limited"]);
48
+ /** A retryable downstream failure (HTTP 502, 503 or 504); a refusal under this code is not retryable. */
49
+ const staleListingAllowed = (code, retryable) => STALE_LISTING_CODES.has(code) || (code === "connector_call_failed" && retryable);
27
50
  const lexicalStores = new WeakMap();
28
51
  let nextLexicalStore = 0;
29
52
  /** How long a probe claim outlives its request's discovery budget, for settlement and clock skew. */
@@ -38,10 +61,12 @@ class DiscoverySkipped extends Error {
38
61
  this.retryAfterMs = retryAfterMs;
39
62
  }
40
63
  }
41
- export const DEFAULT_SEARCH_LIMIT = 8;
64
+ const DEFAULT_SEARCH_LIMIT = 8;
42
65
  export const MAX_SEARCH_LIMIT = 100;
43
66
  export const MAX_DESCRIBE_ADDRESSES = 100;
44
67
  export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
68
+ /** UTF-8 bytes of full tool purposes one describe call returns; fallback summaries have their own bound. */
69
+ export const MAX_DESCRIBE_DESCRIPTION_BYTES = 32 * 1024;
45
70
  const MAX_QUERY_TERMS = 8;
46
71
  const MAX_QUERY_TERM_LENGTH = 64;
47
72
  /**
@@ -268,6 +293,23 @@ function schemaKeyMetadata(input, output) {
268
293
  ...(outputKeys && outputKeys.properties.length > 0 ? { outputKeys: outputKeys.properties } : {}),
269
294
  };
270
295
  }
296
+ /** Fixed text and typed facts only: nothing here comes from the connector. */
297
+ function staleCatalogFailure(connector, stale) {
298
+ // A downstream that asked for time gets it: asking now would only be refused again.
299
+ const advice = stale.code === "rate_limited" || stale.downstreamWait
300
+ ? `Wait${stale.retryAfterMs === undefined ? "" : ` ${stale.retryAfterMs} ms`} before searching with connector "${connector}".`
301
+ : `Search with connector "${connector}" to try it now.`;
302
+ return {
303
+ connector,
304
+ code: stale.code,
305
+ message: `Connector "${connector}" did not answer discovery (${stale.code}), so its tools are listed from its last complete catalog. Calls still check the live catalog. ${advice}`,
306
+ retryable: true,
307
+ ...(stale.retryAfterMs === undefined ? {} : { retryAfterMs: stale.retryAfterMs }),
308
+ stale: true,
309
+ ageMs: stale.ageMs,
310
+ };
311
+ }
312
+ const staleListedText = (count) => `${count} connector catalog${count === 1 ? " is" : "s are"} listed from an older complete catalog; inspect catalogErrors.`;
271
313
  const stringListSchema = { type: "array", items: { type: "string" } };
272
314
  const STRING = { type: "string" };
273
315
  // The reasons enum is stated once under `$defs` and referenced from each field.
@@ -281,6 +323,8 @@ const catalogFailureProperties = {
281
323
  message: { type: "string" },
282
324
  retryable: { type: "boolean" },
283
325
  retryAfterMs: { type: "integer", minimum: 0 },
326
+ stale: { const: true },
327
+ ageMs: { type: "integer" },
284
328
  connector: { type: "string" },
285
329
  recovery: { enum: ["oauth", "operator_config", "unavailable"] },
286
330
  status: { const: "auth_required" },
@@ -309,12 +353,12 @@ const catalogFailureProperties = {
309
353
  // additive action advice compact in the always-loaded search schema.
310
354
  nextAction: {
311
355
  type: "object",
312
- anyOf: [{ required: ["tool"] }, { required: ["action"] }],
356
+ anyOf: [{ required: ["url"] }, { required: ["tool"] }, { required: ["action"] }],
313
357
  properties: {
358
+ url: STRING,
314
359
  tool: { const: "authorize_connector" },
315
360
  action: { enum: ["contact_operator", "inspect_capabilities"] },
316
361
  purpose: STRING,
317
- operatorHandoff: STRING,
318
362
  arguments: { type: "object", properties: { connector: STRING } },
319
363
  },
320
364
  },
@@ -322,10 +366,10 @@ const catalogFailureProperties = {
322
366
  /** The one typed failure (and auth handoff) both failure fields reference. */
323
367
  const FAILURE_REF = { $ref: "#/$defs/failure" };
324
368
  /**
325
- * The outputSchema contract for either search adapter. Guest API registration
326
- * can reuse this value instead of maintaining a second page definition.
327
- * Every failure field references one local `$defs` entry, so the typed auth
328
- * handoff is advertised once instead of once per field.
369
+ * The full page contract for either search adapter, which the server validates
370
+ * every page against. `tools/list` advertises the open summary in `meta-output.ts`
371
+ * instead. Every failure field references one local `$defs` entry, so the typed
372
+ * auth handoff is stated once.
329
373
  */
330
374
  export const CATALOG_SEARCH_RESULT_SCHEMA = {
331
375
  type: "object",
@@ -364,6 +408,7 @@ export const CATALOG_SEARCH_RESULT_SCHEMA = {
364
408
  name: { type: "string" },
365
409
  address: { type: "string" },
366
410
  description: { type: "string" },
411
+ descriptionTruncated: { const: true },
367
412
  inputSchema: {},
368
413
  outputSchema: {},
369
414
  signature: { type: "string" },
@@ -451,6 +496,7 @@ export class CatalogService {
451
496
  searchRoute;
452
497
  requestSignal;
453
498
  readOnly;
499
+ markUncallableWrites;
454
500
  // The request-scoped catalog cache: one shared read per connector asked
455
501
  // about, started by the first asker and joined by every later one. A
456
502
  // success stays while its in-process invalidation fence matches, so a search
@@ -475,6 +521,13 @@ export class CatalogService {
475
521
  // current by its own probes, so repeated searches read no storage.
476
522
  health = new Map();
477
523
  routes;
524
+ // This request's peeks: a stale listing is kept here, never in `catalogs`,
525
+ // because resolve reads `catalogs` and a call must never resolve through it.
526
+ peeks = new Map();
527
+ // At most one deferred refresh per connector per request.
528
+ refreshed = new Set();
529
+ refreshSlots;
530
+ unscopedProbeTimeoutMs;
478
531
  constructor(registry, baseUrl, options = {}) {
479
532
  this.registry = registry;
480
533
  this.baseUrl = baseUrl;
@@ -483,11 +536,13 @@ export class CatalogService {
483
536
  this.requestScope = options.requestScope ?? {};
484
537
  this.probeTimeoutMs = normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
485
538
  this.discoveryBudgetMs = normalizeTimeoutMs(options.discoveryBudgetMs) ?? UNSCOPED_DISCOVERY_BUDGET_MS;
539
+ this.unscopedProbeTimeoutMs = normalizeTimeoutMs(options.unscopedProbeTimeoutMs) ?? UNSCOPED_PROBE_TIMEOUT_MS;
486
540
  this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
487
541
  this.searchRoute = options.searchRoute ?? "search_tools";
488
542
  this.requestSignal = options.requestSignal;
489
543
  this.defer = options.defer;
490
544
  this.readOnly = options.readOnly === true;
545
+ this.markUncallableWrites = options.markUncallableWrites === true;
491
546
  }
492
547
  /**
493
548
  * Send a caller back to discovery through the surface it can actually reach.
@@ -571,10 +626,112 @@ export class CatalogService {
571
626
  if (!view) {
572
627
  view = toolsForSafety(readOnly ? tools.filter((tool) => tool.classification === "read") : tools, safety ?? "all");
573
628
  carryLexicalCatalog(tools, view);
629
+ const stale = catalogStaleness(tools);
630
+ if (stale)
631
+ markCatalogStale(view, stale);
574
632
  views.set(key, view);
575
633
  }
576
634
  return view;
577
635
  }
636
+ // One connector's cached catalog, read at most once per request and identity.
637
+ // Storage only (INV-10), under its own deadline and the request's signal. A
638
+ // read that fails or runs out of time is a miss: discovery never fails on it.
639
+ peek(id, timeoutMs) {
640
+ return withDeadlineEffect((signal) => this.joinPeek(id, signal), {
641
+ ...(this.requestSignal ? { signal: this.requestSignal } : {}),
642
+ timeoutMs,
643
+ timeoutError: new ConnectorCallError("timeout", `catalog peek of "${id}" timed out`),
644
+ }).pipe(Effect.catchCause(() => Effect.succeed(undefined)));
645
+ }
646
+ joinPeek(id, signal) {
647
+ return Effect.suspend(() => {
648
+ const identity = retainedCatalogIdentity(this.registry.contextFor(id, this.baseUrl, this.requestScope));
649
+ let retained = this.peeks.get(id);
650
+ if (!retained || retained.identity !== identity) {
651
+ const started = new SharedRead((signal) => registryProgram(this.registry, "peekCatalog", id, this.baseUrl, this.requestScope, { signal }).pipe(Effect.map((peek) => {
652
+ if (!peek)
653
+ return undefined;
654
+ const connector = this.registry.getConnector(id);
655
+ for (const tool of peek.tools)
656
+ routeToolInPlace(connector, tool, this.routes);
657
+ return peek;
658
+ })), (succeeded) => {
659
+ if (!succeeded && this.peeks.get(id)?.read === started)
660
+ this.peeks.delete(id);
661
+ }, this.requestScope);
662
+ retained = { identity, read: started };
663
+ this.peeks.set(id, retained);
664
+ }
665
+ return retained.read.join(signal);
666
+ });
667
+ }
668
+ /**
669
+ * A peek as this discovery's answer for a connector that did not. Whatever a
670
+ * listing would hide stays hidden: the view is filtered like a live one, and
671
+ * the mark is what makes search and native listing report it.
672
+ */
673
+ staleListing(id, peek, includeWrites, failure) {
674
+ const source = markCatalogStale(peek.tools.map((tool) => cloneTool(tool)), { ...failure, ageMs: Math.max(0, Date.now() - peek.fetchedAt) });
675
+ return this.searchView(id, source, includeWrites, "all");
676
+ }
677
+ /**
678
+ * Refresh a catalog whose probe ran out of time, in a request scope of its
679
+ * own under `defer`, the runtime's tail. Nothing here shares this request's
680
+ * signal, transports or reads, so nothing request-bound outlives the request
681
+ * (INV-7); the refresh owns its transport and closes it within its own
682
+ * deadline, which Workers `waitUntil` covers. It lists like any discovery:
683
+ * a partial walk publishes nothing (INV-8) and no authorization starts
684
+ * (INV-10). The health record's lease keeps other requests from starting
685
+ * another while this one runs, and its result settles that record.
686
+ *
687
+ * Every step answers to one clock, started as it is scheduled, so queueing,
688
+ * a slow claim and a slow settlement spend the same tail. Waiting on storage
689
+ * stops at its limit; a write already sent is left to land or to lapse.
690
+ */
691
+ refreshLater(id, slot, spentMs) {
692
+ const tailMs = Math.min(this.probeTimeoutMs, CATALOG_REFRESH_TIMEOUT_MS);
693
+ const closeMs = Math.min(REFRESH_CLOSE_MS, Math.floor(tailMs / 4));
694
+ const settleMs = Math.min(REFRESH_SETTLE_MS, Math.floor(tailMs / 4));
695
+ if (!this.defer || this.refreshed.has(id) || tailMs - closeMs - settleMs <= spentMs)
696
+ return;
697
+ this.refreshed.add(id);
698
+ const scope = {};
699
+ const connector = this.registry.getConnector(id);
700
+ const endsAt = Date.now() + tailMs;
701
+ const listEnd = endsAt - closeMs - settleMs;
702
+ const timeoutError = new ConnectorCallError("timeout", `catalog refresh of "${id}" timed out`);
703
+ const until = (limit, storage, otherwise) => Effect.suspend(() => limit <= Date.now()
704
+ ? Effect.succeed(otherwise)
705
+ : withDeadlineEffect(() => storage, { timeoutMs: limit - Date.now(), timeoutError }).pipe(Effect.catchCause(() => Effect.succeed(otherwise))));
706
+ const refresh = Effect.gen({ self: this }, function* () {
707
+ const claimed = yield* until(listEnd, claimDiscoveryProbe(slot, endsAt + CLAIM_MARGIN_MS), false);
708
+ // Only what the claim left: a claim that spent it all dispatches nothing,
709
+ // and its lease lapses by itself.
710
+ const timeoutMs = listEnd - Date.now();
711
+ if (!claimed || !connector || timeoutMs <= 0)
712
+ return;
713
+ const started = Date.now();
714
+ const result = yield* Effect.result(withDeadlineEffect((signal) => registryProgram(this.registry, "getTools", id, this.baseUrl, scope, {
715
+ signal,
716
+ timeoutMs,
717
+ ...(this.defer ? { defer: this.defer } : {}),
718
+ }), { timeoutMs, timeoutError }));
719
+ yield* until(endsAt - closeMs, settleDiscoveryHealth(slot, Result.isSuccess(result)
720
+ ? { ok: true }
721
+ : {
722
+ ok: false,
723
+ failure: result.failure,
724
+ timedOut: result.failure === timeoutError,
725
+ elapsedMs: Date.now() - started,
726
+ }), undefined);
727
+ });
728
+ this.refreshSlots ??= Semaphore.makeUnsafe(this.concurrency);
729
+ detach(this.refreshSlots
730
+ .withPermits(1)(refresh)
731
+ .pipe(Effect.ensuring(Effect.suspend(() => connector
732
+ ? closeScope(connector, this.registry.contextFor(id, this.baseUrl, scope), this.defer)
733
+ : Effect.void))), { waitUntil: this.defer });
734
+ }
578
735
  // Every named catalog under its own probe deadline, `concurrency` at a time,
579
736
  // each settling in its input slot: an unavailable catalog is a Failure
580
737
  // there, not a failed discovery. `route` names the surface in a timeout.
@@ -594,11 +751,22 @@ export class CatalogService {
594
751
  // it, then runs to its end. A lapsed record is claimed as its probe is
595
752
  // dispatched, leased to the end of this budget plus a margin.
596
753
  // Caller cancellation and a probe the budget cut short leave health as it was.
597
- searchCatalogs(ids, scoped) {
754
+ //
755
+ // Unscoped search and a native listing also serve a connector's last complete
756
+ // catalog, marked stale, when it did not answer in time. A cached or authored
757
+ // catalog is read without a probe slot, so only a connector that must actually
758
+ // be asked waits for one. A native listing has no budget and gives a connector
759
+ // with nothing to fall back on its full deadline, since it would otherwise vanish.
760
+ searchCatalogs(ids, mode, includeWrites = false) {
598
761
  return Effect.gen({ self: this }, function* () {
599
- const deadline = scoped ? this.probeTimeoutMs : Math.min(this.probeTimeoutMs, UNSCOPED_PROBE_TIMEOUT_MS);
600
- const budgetEnd = scoped ? Infinity : Date.now() + this.discoveryBudgetMs;
601
- const settleReserve = scoped ? 0 : Math.min(SETTLE_RESERVE_MS, Math.floor(this.discoveryBudgetMs / 10));
762
+ const scoped = mode === "scoped";
763
+ const shortDeadline = Math.min(this.probeTimeoutMs, this.unscopedProbeTimeoutMs);
764
+ const budgeted = mode === "unscoped";
765
+ const route = mode === "native" ? "tools/list" : this.searchRoute;
766
+ const budgetEnd = budgeted ? Date.now() + this.discoveryBudgetMs : Infinity;
767
+ const settleReserve = budgeted ? Math.min(SETTLE_RESERVE_MS, Math.floor(this.discoveryBudgetMs / 10)) : 0;
768
+ const slots = yield* Semaphore.make(this.concurrency);
769
+ const peekSlots = yield* Semaphore.make(CATALOG_PEEK_CONCURRENCY);
602
770
  // Health storage starts only while budget remains and the caller is
603
771
  // here; running out is not a failure, health is then unknown. Once
604
772
  // started, an operation is awaited to its end, never raced or detached:
@@ -630,60 +798,142 @@ export class CatalogService {
630
798
  });
631
799
  };
632
800
  const probeOne = (id) => Effect.gen({ self: this }, function* () {
633
- const verdict = scoped ? undefined : this.health.get(id);
634
- const skip = verdict?.skip;
635
- if (skip && skip.retryAt > Date.now())
801
+ const connector = this.registry.getConnector(id);
802
+ // An authored catalog answers from configuration: no slot, no budget, no health.
803
+ if (!scoped && connector?.staticTools)
636
804
  return {
637
805
  id,
638
- result: Result.fail(new DiscoverySkipped(skip.code, Math.ceil(skip.retryAt - Date.now()))),
806
+ result: yield* Effect.result(this.probe(id, includeWrites, this.probeTimeoutMs, new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`))),
639
807
  };
808
+ // Storage only, so a connector with a current catalog never queues
809
+ // behind one that must be asked. A custom connector is always listed
810
+ // anew and starts no cache I/O here; it peeks only if that fails.
811
+ let peek = !scoped && connector?.peekCatalog && Date.now() < budgetEnd
812
+ ? yield* peekSlots.withPermits(1)(this.peek(id, Math.max(shortDeadline, STALE_READ_TIMEOUT_MS)))
813
+ : undefined;
814
+ const verdict = scoped ? undefined : this.health.get(id);
815
+ const skip = verdict?.skip;
816
+ // A peek is only as good as the moment it was read. After any wait for
817
+ // a slot or a probe it is read again, through every acceptance fence,
818
+ // so an invalidation, a rotated credential or a refusal in the
819
+ // meantime retires it, in this isolate or another.
820
+ const staleAnswer = (failure, waited = true) => Effect.gen({ self: this }, function* () {
821
+ // Caller cancellation is not a connector that failed to answer.
822
+ if (scoped || this.requestSignal?.aborted)
823
+ return undefined;
824
+ if (waited)
825
+ this.peeks.delete(id);
826
+ if (waited || !peek)
827
+ peek = yield* this.peek(id, STALE_READ_TIMEOUT_MS);
828
+ return peek ? this.staleListing(id, peek, includeWrites, failure) : undefined;
829
+ });
830
+ if (skip && skip.retryAt > Date.now() && !peek?.fresh) {
831
+ const retryAfterMs = Math.ceil(skip.retryAt - Date.now());
832
+ // Discovery health records only timeouts and retryable failures.
833
+ if (staleListingAllowed(skip.code, true)) {
834
+ const stale = yield* staleAnswer({ code: skip.code, retryAfterMs }, false);
835
+ if (stale)
836
+ return { id, result: Result.succeed(stale) };
837
+ }
838
+ // A native listing has no search to come back through: it still asks.
839
+ if (mode !== "native")
840
+ return { id, result: Result.fail(new DiscoverySkipped(skip.code, retryAfterMs)) };
841
+ }
842
+ // With something to fall back on a short deadline is enough. Without
843
+ // it, a native listing waits its full deadline, as the connector
844
+ // would otherwise be absent; unscoped search keeps its short one.
845
+ const deadline = scoped || (mode === "native" && !peek) ? this.probeTimeoutMs : shortDeadline;
640
846
  const timeoutFor = (remaining) => {
641
847
  const budgetCut = remaining < deadline;
642
848
  return {
643
849
  budgetCut,
644
850
  timeoutMs: budgetCut ? remaining : deadline,
645
851
  timeoutError: budgetCut
646
- ? new ConnectorCallError("timeout", `${this.searchRoute} stopped probing "${id}" at the ${this.discoveryBudgetMs}ms discovery budget`)
647
- : new ConnectorCallError("timeout", `${this.searchRoute} probe of "${id}" timed out after ${deadline}ms`),
852
+ ? new ConnectorCallError("timeout", `${route} stopped probing "${id}" at the ${this.discoveryBudgetMs}ms discovery budget`)
853
+ : new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${deadline}ms`),
648
854
  };
649
855
  };
650
- let plan = timeoutFor(Math.ceil(budgetEnd - settleReserve - Date.now()));
651
- if (plan.timeoutMs <= 0)
652
- return { id, result: Result.fail(plan.timeoutError) };
653
- // A lapsed record: claim this probe now, as it is dispatched. The
654
- // lease runs to an absolute time, the end of this request's
655
- // discovery budget plus a margin, so however long the claim write
656
- // takes, the probe the budget still allows ends inside it.
657
- if (skip && verdict?.slot) {
658
- const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
659
- const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
660
- if (!claimed) {
661
- this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
662
- return {
663
- id,
664
- result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
665
- };
666
- }
667
- this.health.set(id, { slot: verdict.slot });
668
- plan = timeoutFor(Math.ceil(budgetEnd - settleReserve - Date.now()));
856
+ const asked = Effect.gen({ self: this }, function* () {
857
+ // A current catalog is a storage read in hand, not a probe the budget has to bound.
858
+ const remaining = () => (peek?.fresh ? Infinity : Math.ceil(budgetEnd - settleReserve - Date.now()));
859
+ let plan = timeoutFor(remaining());
669
860
  if (plan.timeoutMs <= 0)
670
- return { id, result: Result.fail(plan.timeoutError) };
861
+ return { result: Result.fail(plan.timeoutError), plan };
862
+ // A lapsed record: claim this probe now, as it is dispatched. The
863
+ // lease runs to an absolute time, the end of this request's
864
+ // discovery budget plus a margin, so however long the claim write
865
+ // takes, the probe the budget still allows ends inside it.
866
+ if (budgeted && skip && verdict?.slot && !peek?.fresh) {
867
+ const leaseUntil = budgetEnd + CLAIM_MARGIN_MS;
868
+ const claimed = yield* withinBudget(claimDiscoveryProbe(verdict.slot, leaseUntil), false);
869
+ if (!claimed) {
870
+ this.health.set(id, { slot: verdict.slot, skip: { code: skip.code, retryAt: leaseUntil } });
871
+ return {
872
+ result: Result.fail(new DiscoverySkipped(skip.code, Math.max(1, Math.ceil(leaseUntil - Date.now())))),
873
+ plan,
874
+ };
875
+ }
876
+ this.health.set(id, { slot: verdict.slot });
877
+ plan = timeoutFor(remaining());
878
+ if (plan.timeoutMs <= 0)
879
+ return { result: Result.fail(plan.timeoutError), plan };
880
+ }
881
+ const started = Date.now();
882
+ const result = yield* Effect.result(this.probe(id, includeWrites, plan.timeoutMs, plan.timeoutError));
883
+ // Elapsed time is this probe's own, measured as it settles.
884
+ return { result, plan, elapsedMs: Date.now() - started };
885
+ });
886
+ // A current catalog's read takes no slot: it hands the peek to the listing.
887
+ const { result, plan, elapsedMs } = (peek?.fresh ? yield* asked : yield* slots.withPermits(1)(asked));
888
+ if (elapsedMs === undefined) {
889
+ if (Result.isFailure(result)) {
890
+ const failure = result.failure;
891
+ const stale = yield* staleAnswer(failure instanceof DiscoverySkipped
892
+ ? { code: failure.code, retryAfterMs: failure.retryAfterMs }
893
+ : { code: "timeout" });
894
+ if (stale)
895
+ return { id, result: Result.succeed(stale) };
896
+ }
897
+ return { id, result };
671
898
  }
672
- const started = Date.now();
673
- const result = yield* Effect.result(this.probe(id, false, plan.timeoutMs, plan.timeoutError));
674
- // Elapsed time is this probe's own, measured as it settles.
675
- const elapsedMs = Date.now() - started;
676
899
  const outcome = plan.budgetCut && Result.isFailure(result) && result.failure === plan.timeoutError
677
900
  ? { id, result }
678
901
  : { id, result, settle: { timeoutError: plan.timeoutError, elapsedMs } };
679
902
  if (scoped)
680
903
  return outcome;
681
- yield* settleOne(outcome);
904
+ // A catalog served from cache says nothing about a recorded failure.
905
+ if (!(skip && peek?.fresh))
906
+ yield* settleOne(outcome);
907
+ if (Result.isSuccess(result))
908
+ return { id, result };
909
+ const failure = result.failure;
910
+ // A probe that ran out of time: its abort left nothing cached, so ask
911
+ // again past this request, under the longer deadline the runtime's tail allows.
912
+ if (failure === plan.timeoutError && !plan.budgetCut) {
913
+ // Only the request whose settlement recorded this: anyone else lost
914
+ // to a record that already has an owner, or wrote nothing at all.
915
+ const recorded = this.health.get(id);
916
+ if (recorded?.slot && recorded.recorded)
917
+ this.refreshLater(id, recorded.slot, plan.timeoutMs);
918
+ }
919
+ const classified = failure === plan.timeoutError
920
+ ? { code: "timeout", retryable: true }
921
+ : classifyCallError(failure, "catalog_lookup_failed");
922
+ if (staleListingAllowed(classified.code, classified.retryable)) {
923
+ const stale = yield* staleAnswer({
924
+ code: classified.code,
925
+ ...(classified.retryAfterMs === undefined
926
+ ? {}
927
+ : { retryAfterMs: classified.retryAfterMs, downstreamWait: true }),
928
+ });
929
+ if (stale)
930
+ return { id, result: Result.succeed(stale) };
931
+ }
682
932
  return { id, result };
683
933
  });
684
934
  if (!scoped) {
685
935
  yield* readHealth;
686
- const probed = yield* Effect.forEach(ids, probeOne, { concurrency: this.concurrency });
936
+ const probed = yield* Effect.forEach(ids, probeOne, { concurrency: "unbounded" });
687
937
  return probed.map((outcome) => outcome.result);
688
938
  }
689
939
  const [, probed] = yield* Effect.all([readHealth, Effect.forEach(ids, probeOne, { concurrency: this.concurrency })], { concurrency: 2 });
@@ -700,13 +950,20 @@ export class CatalogService {
700
950
  * settles on its own, so one unavailable connector is an `error` entry
701
951
  * beside the others rather than a failed listing.
702
952
  */
703
- listCatalogs(includeWrites = false, connectors = this.registry.listConnectors()) {
704
- return runEdge(this.listCatalogsEffect(includeWrites, connectors));
953
+ listCatalogs(includeWrites = false, connectors = this.registry.listConnectors(), allowStale = false) {
954
+ return runEdge(this.listCatalogsEffect(includeWrites, connectors, allowStale));
705
955
  }
706
- listCatalogsEffect(includeWrites = false, connectors) {
956
+ /**
957
+ * `allowStale` is for a listing only. Resolving a native call must not pass
958
+ * it: uniqueness and authority come from a catalog that answered.
959
+ */
960
+ listCatalogsEffect(includeWrites = false, connectors, allowStale = false) {
707
961
  return duringViewEffect({ requestScope: this.requestScope, signal: this.requestSignal }, () => Effect.suspend(() => {
708
962
  const visible = connectors ?? this.registry.listConnectors();
709
- return Effect.map(this.discoveryCatalogs(visible.map((connector) => connector.id), "tools/list", includeWrites), (results) => results.map((result, index) => {
963
+ const ids = visible.map((connector) => connector.id);
964
+ return Effect.map(allowStale
965
+ ? this.searchCatalogs(ids, "native", includeWrites)
966
+ : this.discoveryCatalogs(ids, "tools/list", includeWrites), (results) => results.map((result, index) => {
710
967
  const connector = visible[index];
711
968
  return Result.isSuccess(result)
712
969
  ? { connector, tools: carryCatalogFreshness(result.success, result.success.map(cloneTool)) }
@@ -741,7 +998,10 @@ export class CatalogService {
741
998
  cause,
742
999
  };
743
1000
  }
744
- unknownToolFailure(toolName, connector, started) {
1001
+ unknownToolFailure(toolName, connector, started, tools) {
1002
+ // The same view discovery serves: a read-only scope never names a write here.
1003
+ const visible = this.readOnly || connector.readOnly === true ? tools.filter((tool) => tool.classification === "read") : tools;
1004
+ const suggestions = hasControlCharacters(toolName) ? [] : describeSuggestions(connector.id, toolName, visible);
745
1005
  return {
746
1006
  ok: false,
747
1007
  error: {
@@ -749,6 +1009,7 @@ export class CatalogService {
749
1009
  ? `Unknown tool on connector "${connector.id}"`
750
1010
  : `Unknown tool "${boundedEchoText(toolName)}" on connector "${connector.id}"`),
751
1011
  nextAction: this.searchRecovery({ query: hasControlCharacters(toolName) ? "" : recoveryQuery(toolName), connector: connector.id }, "Find the connector's current canonical tool address."),
1012
+ ...(suggestions.length > 0 ? { suggestions } : {}),
752
1013
  },
753
1014
  catalogMs: Date.now() - started,
754
1015
  connector,
@@ -774,7 +1035,7 @@ export class CatalogService {
774
1035
  onSuccess: (tools) => {
775
1036
  const definition = tools.find((tool) => tool.name === toolName);
776
1037
  if (!definition) {
777
- return this.unknownToolFailure(toolName, connector, started);
1038
+ return this.unknownToolFailure(toolName, connector, started, tools);
778
1039
  }
779
1040
  return {
780
1041
  ok: true,
@@ -888,7 +1149,7 @@ export class CatalogService {
888
1149
  : undefined;
889
1150
  const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
890
1151
  // Timeout messages name the route the caller used.
891
- const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined);
1152
+ const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined ? "scoped" : "unscoped");
892
1153
  const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => {
893
1154
  const id = connectors[index].id;
894
1155
  const ctx = this.registry.contextFor(id, this.baseUrl, this.requestScope);
@@ -978,21 +1239,36 @@ export class CatalogService {
978
1239
  order: original.success.indexOf(tool),
979
1240
  exactName: false,
980
1241
  matchedTermCount: toolTermCount,
1242
+ headTerms: [],
1243
+ nameTerms: [],
1244
+ namesDistinctiveTerm: false,
981
1245
  });
982
1246
  }
983
1247
  }
1248
+ // A tool on the named connector need not repeat that connector's name.
1249
+ const statedTerms = args.connector || exactConnector ? toolTerms.filter((term) => !connectorTerms.has(term)) : toolTerms;
1250
+ const requiredTerms = statedTerms.length > 0 ? statedTerms : toolTerms;
984
1251
  for (const ranked of rankedTools) {
1252
+ const complete = isBrowse || requiredTerms.every((term) => ranked.headTerms.includes(term));
1253
+ const vocabulary = vocabularyTools.has(ranked.tool);
1254
+ // Discovery pointers follow every tool that answers lexically.
1255
+ const vocabularyRank = complete ? 0 : vocabularyScore(requiredTerms, statistics);
985
1256
  collected.push({
986
1257
  connector,
987
1258
  tool: ranked.tool,
988
- score: vocabularyTools.has(ranked.tool) && !ranked.exactName ? 0 : ranked.score,
1259
+ score: vocabulary && !ranked.exactName ? vocabularyRank : ranked.score,
989
1260
  order: orderBase + ranked.order,
990
- exactName: ranked.exactName,
1261
+ // A tool named just `search` is not what a longer query containing that verb names.
1262
+ exactName: ranked.exactName && !ACTION_WORD.test(ranked.tool.name.toLowerCase()),
991
1263
  exactLookup: rawPhrase === lexicalQueryTerms(ranked.tool.name).join(" ") ||
992
1264
  rawPhrase === lexicalQueryTerms(`${connector.id}.${ranked.tool.name}`).join(" "),
993
1265
  connectorRank,
994
1266
  matchedTermCount: vocabularyTools.has(ranked.tool) ? toolTermCount : ranked.matchedTermCount,
995
- complete: isBrowse || vocabularyTools.has(ranked.tool) || ranked.matchedTermCount === toolTermCount,
1267
+ complete,
1268
+ covered: isBrowse || vocabulary || ranked.matchedTermCount === toolTermCount,
1269
+ vocabulary,
1270
+ namesSubject: ranked.nameTerms.some((term) => !ACTION_WORD.test(term) && !connectorTerms.has(term)),
1271
+ namesDistinctiveTerm: ranked.namesDistinctiveTerm,
996
1272
  });
997
1273
  }
998
1274
  }
@@ -1005,12 +1281,13 @@ export class CatalogService {
1005
1281
  // unrelated zero-score match, with no coverage to explain the result.
1006
1282
  const rankedMatches = unsearchableQuery ? [] : collectMatches(isBrowse ? "all" : "partial");
1007
1283
  const completeMatchCount = rankedMatches.filter((match) => match.complete).length;
1008
- matches.push(...rankedMatches.filter((match) => match.complete ||
1284
+ matches.push(...rankedMatches.filter((match) => match.covered ||
1285
+ match.namesDistinctiveTerm ||
1009
1286
  completeMatchCount === 0 ||
1010
1287
  match.exactName ||
1011
1288
  match.connectorRank > 0 ||
1012
1289
  match.matchedTermCount >= 2));
1013
- if (!isBrowse && !unsearchableQuery && completeMatchCount === 0) {
1290
+ if (!isBrowse && !unsearchableQuery && !rankedMatches.some((match) => match.covered)) {
1014
1291
  matchMode = "partial";
1015
1292
  }
1016
1293
  // A read-intent verb leads the query once connector names are set aside.
@@ -1021,13 +1298,15 @@ export class CatalogService {
1021
1298
  // longer name such as get_issue_status cannot beat an exact get_issue
1022
1299
  // merely because its description repeats rare query terms. Within a
1023
1300
  // tier, a read-intent query puts reads before writes.
1301
+ // A hatch with a matching operation stays above partial matches, as does a
1302
+ // tool that names the query's subject and states the rest in its tail. A
1303
+ // tail alone earns no tier: long prose can mention anything.
1304
+ const tier = (match) => match.complete || match.exactName ? 2 : match.vocabulary || (match.covered && match.namesSubject) ? 1 : 0;
1024
1305
  matches.sort((a, b) => {
1025
- const aFirstTier = a.complete || a.exactName;
1026
- const bFirstTier = b.complete || b.exactName;
1027
1306
  return (Number(b.exactLookup) - Number(a.exactLookup) ||
1028
1307
  Number(b.exactName) - Number(a.exactName) ||
1029
1308
  b.connectorRank - a.connectorRank ||
1030
- Number(bFirstTier) - Number(aFirstTier) ||
1309
+ tier(b) - tier(a) ||
1031
1310
  readFirst(b) - readFirst(a) ||
1032
1311
  b.score - a.score ||
1033
1312
  a.order - b.order);
@@ -1056,7 +1335,10 @@ export class CatalogService {
1056
1335
  ? renderSearchSchema(output.schema, schemaFormat)
1057
1336
  : undefined;
1058
1337
  const schemaKeys = withSchemas && args.includeSchemaKeys ? schemaKeyMetadata(input, output.schema) : undefined;
1059
- const description = summarizeDiscoveryDescription(match.tool.description, args.fullDescriptions === true);
1338
+ const summary = match.tool.description && args.fullDescriptions !== true
1339
+ ? discoverySummary(match.tool.description)
1340
+ : undefined;
1341
+ const description = summary ? summary.text : match.tool.description || undefined;
1060
1342
  const requiredReasons = guideRequiredReasons(match.connector, match.tool, renderedInput?.truncated === true || renderedOutput?.truncated === true);
1061
1343
  const guideSummary = connectorGuideSummary(match.connector);
1062
1344
  return {
@@ -1071,6 +1353,7 @@ export class CatalogService {
1071
1353
  name: match.tool.name,
1072
1354
  address: `${match.connector.id}.${match.tool.name}`,
1073
1355
  ...(description !== undefined ? { description } : {}),
1356
+ ...(summary?.truncated ? { descriptionTruncated: true } : {}),
1074
1357
  ...(schemaFormat
1075
1358
  ? {
1076
1359
  inputSchema: renderedInput?.schema,
@@ -1095,6 +1378,7 @@ export class CatalogService {
1095
1378
  ...(schemaKeys?.outputKeys && !renderedOutput?.truncated ? { outputKeys: schemaKeys.outputKeys } : {}),
1096
1379
  ...(match.tool.annotations ? { annotations: match.tool.annotations } : {}),
1097
1380
  classification: match.tool.classification,
1381
+ ...(this.markUncallableWrites && readProgramRefuses(match.tool) ? { callable: false } : {}),
1098
1382
  ...(requiredReasons
1099
1383
  ? {
1100
1384
  guideRequired: true,
@@ -1161,14 +1445,19 @@ export class CatalogService {
1161
1445
  }
1162
1446
  }
1163
1447
  const unavailableCatalogs = catalogs.filter(Result.isFailure).length;
1164
- const catalogErrors = (yield* Effect.forEach(catalogs, (catalog, index) => Result.isSuccess(catalog)
1165
- ? Effect.succeed([])
1166
- : this.catalogFailure(connectors[index], catalog.failure).pipe(Effect.map((error) => [error])), { concurrency: "unbounded" })).flat();
1448
+ const staleCatalogs = catalogs.filter((catalog) => Result.isSuccess(catalog) && catalogStaleness(catalog.success)).length;
1449
+ const catalogErrors = (yield* Effect.forEach(catalogs, (catalog, index) => {
1450
+ if (Result.isFailure(catalog))
1451
+ return this.catalogFailure(connectors[index], catalog.failure).pipe(Effect.map((error) => [error]));
1452
+ const stale = catalogStaleness(catalog.success);
1453
+ return Effect.succeed(stale ? [staleCatalogFailure(connectors[index].id, stale)] : []);
1454
+ }, { concurrency: "unbounded" })).flat();
1167
1455
  const needsAuth = (error) => error.code === "auth_required" ||
1168
1456
  error.code === "downstream_oauth_required" ||
1169
1457
  error.code === "provider_permission_denied";
1170
1458
  catalogErrors.sort((a, b) => Number(needsAuth(b)) - Number(needsAuth(a)));
1171
- const scopedFailure = args.connector ? catalogErrors[0] : undefined;
1459
+ // A stale row beside listed tools is not this scoped search's failure.
1460
+ const scopedFailure = args.connector ? catalogErrors.find((error) => !error.stale) : undefined;
1172
1461
  const scopedCatalogError = scopedFailure
1173
1462
  ? scopedFailure.code === "auth_required" || scopedFailure.code === "downstream_oauth_required"
1174
1463
  ? scopedFailure
@@ -1238,6 +1527,7 @@ export class CatalogService {
1238
1527
  (unavailableCatalogs
1239
1528
  ? ` ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable; inspect catalogErrors.`
1240
1529
  : "") +
1530
+ (staleCatalogs ? ` ${staleListedText(staleCatalogs)}` : "") +
1241
1531
  filterRecovery;
1242
1532
  // A scope that resolved to nothing is the same silence one step earlier in
1243
1533
  // the lookup: no connector resolved, so no catalog was even attempted, so
@@ -1272,7 +1562,7 @@ export class CatalogService {
1272
1562
  }
1273
1563
  if (queryTerms.length === 0) {
1274
1564
  if (unavailableCatalogs === 0)
1275
- return undefined;
1565
+ return staleCatalogs ? staleListedText(staleCatalogs) : undefined;
1276
1566
  return scopedConnector
1277
1567
  ? `Connector "${scopedConnector.id}" could not be browsed because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
1278
1568
  : `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Inspect catalogErrors for the typed reasons and recovery actions.`;
@@ -1291,8 +1581,10 @@ export class CatalogService {
1291
1581
  if (identityGuidance || apiSearchGuidance)
1292
1582
  return ([identityGuidance, apiSearchGuidance].filter(Boolean).join(" ") +
1293
1583
  (apiSearchGuidance ? "" : filterRecovery));
1584
+ if (unavailableCatalogs === 0 && staleCatalogs)
1585
+ return `No matching ${safetyLabel}capability was found. ${staleListedText(staleCatalogs)} Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`;
1294
1586
  return unavailableCatalogs === 0
1295
- ? `No matching ${safetyLabel}capability is configured in this deployment. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`
1587
+ ? `No ${safetyLabel}tool matched these terms. Try other terms, scope by connector, or browse with an empty query.${filterRecovery}`
1296
1588
  : `No matching ${safetyLabel}capability was found in the catalogs that answered; ${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable. Refine terms, scope by connector, or browse with an empty query.${filterRecovery}`;
1297
1589
  }
1298
1590
  if (matchMode !== "partial")
@@ -1399,6 +1691,10 @@ export class CatalogService {
1399
1691
  : undefined;
1400
1692
  return [id, error];
1401
1693
  }), { concurrency: "unbounded" }));
1694
+ // Full prose shares one budget in address order, so a large batch keeps its
1695
+ // schemas and a repeated describe renders the same text. A fallback summary
1696
+ // spends none of it: every entry keeps its purpose, within 480 bytes each.
1697
+ let descriptionBytes = MAX_DESCRIBE_DESCRIPTION_BYTES;
1402
1698
  return resolved.map(({ address, resolved: addressResolution }) => {
1403
1699
  if (!addressResolution) {
1404
1700
  const message = `Unknown address "${boundedEchoText(address)}"`;
@@ -1451,7 +1747,12 @@ export class CatalogService {
1451
1747
  const tool = cloneTool(selected);
1452
1748
  const input = tool.inputSchema ?? { type: "object" };
1453
1749
  const output = this.outputSchema(addressResolution.connector.id, tool);
1454
- const description = summarizeDescription(tool.description, args.fullDescriptions === true);
1750
+ const bytes = tool.description ? encoder.encode(tool.description).length : 0;
1751
+ const withinBudget = args.fullDescriptions === true || bytes <= descriptionBytes;
1752
+ if (withinBudget)
1753
+ descriptionBytes -= bytes;
1754
+ const summary = withinBudget || !tool.description ? undefined : discoverySummary(tool.description);
1755
+ const description = summary ? summary.text : tool.description || undefined;
1455
1756
  const signature = format === "typescript"
1456
1757
  ? typescriptSignature(input, output.schema, {
1457
1758
  observed: output.source === "observed",
@@ -1469,6 +1770,7 @@ export class CatalogService {
1469
1770
  schemaFormat: format === "json" ? "json" : "text",
1470
1771
  name: tool.name,
1471
1772
  ...(description !== undefined ? { description } : {}),
1773
+ ...(summary?.truncated ? { descriptionTruncated: true } : {}),
1472
1774
  ...(connectorGuide(addressResolution.connector)
1473
1775
  ? {
1474
1776
  guide: connectorSkillName(addressResolution.connector.id),
@@ -1492,6 +1794,7 @@ export class CatalogService {
1492
1794
  ...(output.source ? { outputSchemaSource: output.source } : {}),
1493
1795
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
1494
1796
  classification: tool.classification,
1797
+ ...(this.markUncallableWrites && readProgramRefuses(tool) ? { callable: false } : {}),
1495
1798
  };
1496
1799
  });
1497
1800
  });