@zackbart/connecta 0.40.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 (195) hide show
  1. package/CHANGELOG.md +148 -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 +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -5,7 +5,7 @@ import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from
5
5
  import { viewServices, viewSignal } from "../runtime/call.js";
6
6
  import { declareSelfHostedClient } from "../auth/downstream-client-metadata.js";
7
7
  import { isInputRequiredResult } from "@modelcontextprotocol/client";
8
- import { Duration, Effect, Exit, Scope } from "effect";
8
+ import { Duration, Effect } from "effect";
9
9
  import { byteReadResponse } from "../byte-read-response.js";
10
10
  import { redactSentSecrets, requestSentSecrets, sentSecretsFor } from "../sent-secrets.js";
11
11
  import { closeCatalogCacheScope } from "../catalog-cache.js";
@@ -28,7 +28,7 @@ import { providerBoundary } from "../provider-call.js";
28
28
  import { downstreamCallError, hasSdkPayload, payloadFree, remoteMcpErrors } from "./remote-mcp/errors.js";
29
29
  import { LOCAL_CLOSE_BUDGET_MS, REDIRECT_STATUSES, remoteMcpTransport } from "./remote-mcp/transport.js";
30
30
  import { remoteMcpConfig } from "./remote-mcp/config.js";
31
- import { remoteMcpConnection } from "./remote-mcp/connection.js";
31
+ import { closeConnectionScope, remoteMcpConnection } from "./remote-mcp/connection.js";
32
32
  import { remoteMcpOAuth } from "./remote-mcp/oauth.js";
33
33
  import { remoteMcpCatalog } from "./remote-mcp/catalog.js";
34
34
  import { remoteMcpResources } from "./remote-mcp/resources.js";
@@ -155,7 +155,7 @@ export function remoteMcp(id, opts) {
155
155
  Object.assign(rt, remoteMcpCatalog(rt));
156
156
  Object.assign(rt, remoteMcpResources(rt));
157
157
  Object.assign(rt, remoteMcpCall(rt));
158
- const { listSkills, readSkill, liveListings, listToolsOperation, callToolOperation, readResourceOperation, entryFor, cacheOwner, reset, stateFor, newProvider, resourceTemplateRefusals, ensureConnected, granted, connectedProviders, CredentialRequiredError, OperatorDisconnectedError, UnresolvedTokenError, oauthSurface, activeContexts, states, isClosed, scopeEndedError, operatorDisconnectedError, authRequiredError, renewBeforeCall, } = rt;
158
+ const { listSkills, readSkill, liveListings, listToolsOperation, peekCatalogOperation, callToolOperation, readResourceOperation, entryFor, cacheOwner, reset, stateFor, newProvider, resourceTemplateRefusals, ensureConnected, granted, connectedProviders, CredentialRequiredError, OperatorDisconnectedError, UnresolvedTokenError, oauthSurface, activeContexts, states, isClosed, scopeEndedError, operatorDisconnectedError, authRequiredError, renewBeforeCall, } = rt;
159
159
  // Built once from construction-time values: names, framing, and public
160
160
  // URLs only. A static header's value and a stored credential never reach
161
161
  // it, and a URL keeps origin and path: a CIMD document's query may carry a
@@ -261,17 +261,18 @@ export function remoteMcp(id, opts) {
261
261
  }
262
262
  : {}),
263
263
  listTools: listToolsOperation,
264
+ peekCatalog: peekCatalogOperation,
264
265
  callTool: callToolOperation,
265
266
  readResource: readResourceOperation,
266
267
  closeScope: authMethod((ctx) => authFlow(function* () {
267
268
  // Revocation and all finalizers start before any caller guard. Cleanup
268
269
  // retains its own bounded settlement even after the request has closed.
269
270
  const state = entryFor(ctx);
270
- const release = Scope.closeUnsafe(state.scope, Exit.void);
271
+ const release = closeConnectionScope(state);
271
272
  const cacheClosing = closeCatalogCacheScope(ctx, id, cacheOwner);
272
273
  reset(state);
273
274
  const closing = runEdge(Effect.all([
274
- release ?? Effect.void,
275
+ Effect.promise(() => release),
275
276
  cacheClosing
276
277
  ? Effect.raceAllFirst([
277
278
  Effect.promise(() => cacheClosing),
@@ -386,6 +387,11 @@ export function remoteMcp(id, opts) {
386
387
  ctx,
387
388
  authMethod(() => authCall(listTools, [ctx])),
388
389
  ]));
390
+ const peekCatalog = connector.peekCatalog;
391
+ connector.peekCatalog = authMethod((ctx) => authCall(withActiveSecrets, [
392
+ ctx,
393
+ authMethod(() => authCall(peekCatalog, [ctx])),
394
+ ]));
389
395
  const readResource = connector.readResource;
390
396
  connector.readResource = authMethod((uri, ctx) => authCall(withActiveSecrets, [
391
397
  ctx,
@@ -426,6 +432,7 @@ export function remoteMcp(id, opts) {
426
432
  return (yield authView(ctx, authMethod(() => authCall(operation, [...args])), requestOwned));
427
433
  }));
428
434
  connector.listTools = settlingOperation(connector.listTools, 0);
435
+ connector.peekCatalog = settlingOperation(connector.peekCatalog, 0);
429
436
  connector.readResource = settlingOperation(connector.readResource, 1);
430
437
  if (connector.downstreamSkills) {
431
438
  connector.downstreamSkills.list = settlingOperation(connector.downstreamSkills.list, 0);
@@ -445,6 +452,7 @@ export function remoteMcp(id, opts) {
445
452
  // a refresh flight exists. These operations do not take call admission.
446
453
  const retain = retainOperation;
447
454
  connector.listTools = retain(connector.listTools, 0);
455
+ connector.peekCatalog = retain(connector.peekCatalog, 0);
448
456
  connector.callTool = retain(connector.callTool, 2);
449
457
  connector.readResource = retain(connector.readResource, 1);
450
458
  if (connector.downstreamSkills) {
@@ -460,6 +468,7 @@ export function remoteMcp(id, opts) {
460
468
  connector.finishAuth = retain(connector.finishAuth, 1);
461
469
  }
462
470
  connector.listTools = payloadFree(connector.listTools);
471
+ connector.peekCatalog = payloadFree(connector.peekCatalog);
463
472
  connector.callTool = payloadFree(connector.callTool);
464
473
  connector.readResource = payloadFree(connector.readResource);
465
474
  if (connector.downstreamSkills) {
@@ -39,7 +39,9 @@ export interface CredentialVault {
39
39
  * Encrypt downstream OAuth state (tokens, a registered client, a PKCE
40
40
  * verifier) before it enters storage. `purpose` is the storage key the value
41
41
  * is written under, so the result is bound to one connector, owner, and key.
42
- * Without both `seal` and `open`, that state is stored as plaintext.
42
+ * Construction refuses a vault without both `seal` and `open` when any
43
+ * connector has `startAuth`, so that state is never left plaintext beside
44
+ * credentials this vault encrypts.
43
45
  */
44
46
  seal?(connectorId: string, purpose: string, plaintext: string, owner?: string): Promise<string>;
45
47
  /** Sign browser OAuth handoffs with a purpose-specific key; nothing is stored. */
@@ -1,3 +1,4 @@
1
+ import type { ClassificationOverride } from "./tool-safety.js";
1
2
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
2
3
  import type { ConnectaConfig, ResolvedConfig } from "./config.js";
3
4
  import { type ResolvedPool } from "./membership.js";
@@ -95,7 +96,7 @@ export interface ConnectaConfigDescription {
95
96
  maxResultBytes: DescribedLimit;
96
97
  };
97
98
  results: Record<keyof typeof CONFIG_DEFAULTS.results, DescribedLimit>;
98
- execute: Record<keyof typeof CONFIG_DEFAULTS.execute, DescribedLimit>;
99
+ execute: Record<keyof typeof CONFIG_DEFAULTS.execute, DescribedLimit<number | boolean>>;
99
100
  requests: Record<keyof typeof CONFIG_DEFAULTS.admission.requests, DescribedLimit>;
100
101
  };
101
102
  /** Whether the default /mcp endpoint refuses every write, and exact classification overrides from deployment code. */
@@ -115,7 +116,7 @@ export interface ConnectaConfigDescription {
115
116
  id: string;
116
117
  pools: string[];
117
118
  }>;
118
- classification: Record<string, Record<string, "read" | "write" | "hidden">>;
119
+ classification: Record<string, Record<string, ClassificationOverride>>;
119
120
  /** Inbound auth providers in the order they are tried. */
120
121
  auth: Array<{
121
122
  kind: string;
@@ -151,6 +152,7 @@ export interface ConnectaConfigDescription {
151
152
  };
152
153
  activity: {
153
154
  enabled: boolean;
155
+ operations?: true;
154
156
  readable?: boolean;
155
157
  deploymentId?: string;
156
158
  store?: {
@@ -269,6 +269,7 @@ export function describeConfig(input) {
269
269
  ? {
270
270
  enabled: true,
271
271
  readable: typeof config.activity.store.list === "function",
272
+ ...(config.activity.operations ? { operations: true } : {}),
272
273
  ...(str(config.activity.deploymentId) !== undefined ? { deploymentId: config.activity.deploymentId } : {}),
273
274
  store: {
274
275
  kind: storeKind(activityStore),
@@ -5,9 +5,10 @@
5
5
  // the connector-health family's `discovery` key, a typed code, when it was
6
6
  // seen, and when to try again (INV-6: never downstream text, tools or
7
7
  // credentials). Unscoped search skips a connector whose record is current and
8
- // reports it as unavailable in `catalogErrors` with `retryAfterMs`; it never
9
- // serves tools from the failed or interrupted walk (INV-8). Scoped search,
10
- // describe and calls still reach the connector.
8
+ // reports it as unavailable in `catalogErrors` with `retryAfterMs`, listing its
9
+ // last complete catalog, marked stale, when one exists; it never serves tools
10
+ // from the failed or interrupted walk (INV-8). Scoped search, describe and calls
11
+ // still reach the connector.
11
12
  //
12
13
  // Records sit beside the catalog cache, in its store and owner partition:
13
14
  // principal, subject, caller identity and pool, origin, the admitted endpoint,
@@ -21,7 +22,9 @@
21
22
  //
22
23
  // After its retry time a record lingers, so exactly one request can claim the
23
24
  // next probe by compare-and-set as it dispatches it; the others keep skipping
24
- // until that probe settles or its lease lapses. A success, or an auth failure that proves the
25
+ // until that probe settles or its lease lapses. Every write is conditional on
26
+ // the record its request read, so concurrent failures record once and a lease
27
+ // is never overwritten. A success, or an auth failure that proves the
25
28
  // connector answers, clears the record. A fast failure is not recorded:
26
29
  // asking again costs the next search nothing. Caller cancellation and a probe cut
27
30
  // short by the discovery budget record nothing.
@@ -32,7 +35,7 @@ import { classificationCode, classifyCallError } from "./errors.js";
32
35
  import { failureRecord, logFailure } from "./operator-record.js";
33
36
  import { causeError } from "./runtime/run.js";
34
37
  import { Storage } from "./runtime/services.js";
35
- import { storageCompareAndSet, storageGetMany, storageSet } from "./runtime/storage.js";
38
+ import { storageCompareAndSet, storageGetMany } from "./runtime/storage.js";
36
39
  import { connectorHealthKeys, CONNECTOR_HEALTH_LINGER_MS } from "./storage/keys.js";
37
40
  /** How long unscoped search skips a connector after a failed discovery. */
38
41
  const DISCOVERY_RETRY_MS = 60_000;
@@ -123,13 +126,22 @@ export function readDiscoveryHealth(registry, ids, baseUrl, requestScope, option
123
126
  * dispatched: one compare-and-set against the record this request read, with
124
127
  * a lease to the absolute time `leaseUntil`, past the claimant's whole
125
128
  * discovery budget. Others keep skipping until the lease lapses or the probe
126
- * settles. False when another request holds it, or the record changed.
129
+ * settles. An absent record is claimed the same way, by creating it. False when
130
+ * another request holds it, or the record changed.
127
131
  */
128
132
  export function claimDiscoveryProbe(slot, leaseUntil) {
129
133
  return Effect.suspend(() => {
130
- const fact = parseFact(slot.raw);
134
+ const fact = slot.raw === null
135
+ ? {
136
+ v: 1,
137
+ code: "timeout",
138
+ observedAt: Date.now(),
139
+ retryAt: leaseUntil,
140
+ generation: slot.generation,
141
+ }
142
+ : parseFact(slot.raw);
131
143
  if (!fact)
132
- return Effect.succeed(slot.raw === null);
144
+ return Effect.succeed(false);
133
145
  const claim = encodeFact({ ...fact, retryAt: leaseUntil });
134
146
  return storageCompareAndSet(slot.key, slot.raw, claim.raw, { expiresAtMs: claim.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map((claimed) => {
135
147
  if (claimed)
@@ -188,9 +200,12 @@ export function settleDiscoveryHealth(slot, outcome) {
188
200
  }
189
201
  const encoded = encodeFact(fact);
190
202
  const skip = { code: fact.code, retryAt: fact.retryAt };
191
- return storageSet(slot.key, encoded.raw, { expiresAtMs: encoded.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map(() => {
192
- slot.raw = encoded.raw;
193
- return { slot, skip };
203
+ // Against the record this request read: a concurrent failure or a held
204
+ // lease stays, and this request learns it does not own what follows.
205
+ return storageCompareAndSet(slot.key, slot.raw, encoded.raw, { expiresAtMs: encoded.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map((recorded) => {
206
+ if (recorded)
207
+ slot.raw = encoded.raw;
208
+ return { slot, skip, recorded };
194
209
  }), Effect.catchCause((cause) => Effect.sync(() => {
195
210
  logFailure(slot.logger, "discovery health write failed", failureRecord({}, causeError(cause)));
196
211
  return { slot, skip };
@@ -316,12 +316,21 @@ export class DownstreamElicitation {
316
316
  // The nonce is spent even when the downstream failure would normally
317
317
  // allow a retry. Preserve write reconciliation and auth prerequisites.
318
318
  const echoed = downstreamArgumentEcho(self.options.requestScope);
319
+ const recoveryError = { ...error };
320
+ const freshRead = state.tool === "call_tool" &&
321
+ error.code !== "provider_permission_denied" &&
322
+ error.nextAction === undefined &&
323
+ error.recovery === undefined &&
324
+ error.writeOutcome !== "unknown" &&
325
+ error.uncertainCall === undefined;
326
+ if (freshRead)
327
+ delete recoveryError.retry;
319
328
  const structuredContent = {
320
329
  ...result.structuredContent,
321
330
  error: {
322
- ...error,
331
+ ...recoveryError,
323
332
  retryable: false,
324
- ...(state.tool === "call_tool" && error.nextAction === undefined && error.retry === undefined
333
+ ...(freshRead
325
334
  ? {
326
335
  nextAction: {
327
336
  tool: state.tool,
package/dist/errors.d.ts CHANGED
@@ -24,7 +24,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
24
24
  * change, and retries with the base `current` reports — repeating the same
25
25
  * call can never succeed, and waiting will not help either.
26
26
  */
27
- | "conflict" | "resource_template_ambiguous" | "resource_match_budget_exceeded" | "input_required_unsupported" | "input_required_invalid" | "input_required_limit" | "connector_call_failed";
27
+ | "conflict" | "resource_template_ambiguous" | "resource_match_budget_exceeded" | "unsupported_capability" | "input_required_unsupported" | "input_required_invalid" | "input_required_limit" | "connector_call_failed";
28
28
  /**
29
29
  * Every classification code connecta assigns: a connector's typed codes, the
30
30
  * executor's admission codes, and the refusals connecta frames itself.
@@ -33,7 +33,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
33
33
  * Adding a member fails typecheck until {@link CLASSIFICATION_CODE_TABLE}
34
34
  * lists it.
35
35
  */
36
- export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "destructive_tool_requires_approval" | "read_only_endpoint" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
36
+ export type ClassificationCode = ConnectorCallErrorCode | ExecutorAdmissionErrorCode | "cancelled" | "unknown_address" | "unknown_tool" | "ambiguous_tool_alias" | "catalog_lookup_failed" | "result_too_large" | "result_not_retained" | "destructive_tool_requires_approval" | "read_only_endpoint" | "auth_replay_refused" | "write_outcome_unknown" | "result_processing_failed" | "budget_exceeded" | "executor_failed" | "program_error";
37
37
  /** `value` when it is a code connecta assigns, else undefined. */
38
38
  export declare function classificationCode(value: unknown): ClassificationCode | undefined;
39
39
  /**
@@ -101,12 +101,48 @@ export interface ArgumentRepairDetails {
101
101
  * finding stays a few hundred bytes inside the 4 KiB envelope below.
102
102
  */
103
103
  export declare const MAX_ARGUMENT_VALIDATION_ISSUES = 8;
104
+ /** Whitespace and the quotes and brackets that delimit a link in text, HTML and JSON. Each is
105
+ * ASCII, so a cut before one splits no codepoint in UTF-8 or UTF-16. */
106
+ export declare const TOKEN_END: Set<number>;
104
107
  /**
105
- * Clamp a string to a UTF-8 byte budget, appending `…` when it clipped.
106
- * Short strings — the common case, and the one that has to stay exact — are
108
+ * The first `units` UTF-16 units of `value`, less any unfinished token. Every cut of text
109
+ * that output redaction reads later goes through this or a sibling below: a remnant shorter
110
+ * than a fragment matches nothing, so a credential registered after the cut must not leave
111
+ * its opening behind. A whole token is the only tail a cut can prove finished.
112
+ */
113
+ export declare function tokenPrefix(value: string, units: number): string;
114
+ /**
115
+ * Clamp a string to a UTF-8 byte budget without cutting a token, appending `…` when it
116
+ * clipped. Short strings — the common case, and the one that has to stay exact — are
107
117
  * returned unchanged and untagged.
108
118
  */
109
119
  export declare function boundedEchoText(value: string, maxBytes?: number): string;
120
+ /**
121
+ * Clamp a name for an operator's record, which no agent reads and no output redaction
122
+ * follows, to a UTF-8 byte budget. Its prefix is what identifies it, so it may end inside a
123
+ * token; agent-facing text goes through {@link boundedEchoText}.
124
+ */
125
+ export declare function clampedRecordName(value: string, maxBytes: number): string;
126
+ /**
127
+ * How much of a tool's own `isError` text reaches the agent. A refusal can be the
128
+ * only copy of what to do next: a paragraph of instructions, a request id and a
129
+ * signed approval URL, which alone commonly run to a couple of kilobytes. Four
130
+ * kilobytes holds those, and in a direct result's two copies stays well inside the
131
+ * default result cap; a tighter cap shrinks it further.
132
+ */
133
+ export declare const MAX_TOOL_ERROR_BYTES = 4096;
134
+ /**
135
+ * Clamp downstream prose to a UTF-8 byte budget, marker included, without cutting
136
+ * a token. The cut backs off to a byte no URL or credential holds, so a link arrives
137
+ * whole or not at all, and no prefix of a credential registered later stays behind.
138
+ * Each such byte is ASCII, so no codepoint is split.
139
+ */
140
+ export declare function clipText(value: string, maxBytes?: number): string;
141
+ /**
142
+ * Clamp text so its JSON string fits `maxChars` serialized characters, quotes and marker
143
+ * included, without cutting a token. Escapes make a character cost up to six.
144
+ */
145
+ export declare function clipSerialized(value: string, maxChars: number): string;
110
146
  /**
111
147
  * Snapshot the caller's arguments after bounded schema-aware writeOnly omission,
112
148
  * then apply the shared byte budget without clipping. Unresolved sensitivity
@@ -179,6 +215,8 @@ export declare class WithheldTextError extends Error {
179
215
  }
180
216
  /** Agent-visible recovery class for downstream credential failures. */
181
217
  export type AuthRecoveryMode = "oauth" | "operator_config" | "unavailable";
218
+ /** The downstream said this caller has no access: affirmative evidence, unlike a failure to answer. */
219
+ export declare function isAccessRefusal(error: unknown): boolean;
182
220
  /** Runtime membership in the closed connector taxonomy, checked against its exhaustive table. */
183
221
  export declare function isConnectorCallErrorCode(value: unknown): value is ConnectorCallErrorCode;
184
222
  /**
@@ -283,13 +321,31 @@ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
283
321
  * {@link ConnectorCallError.writeOutcome}). Check its target before repeating it.
284
322
  */
285
323
  writeOutcome?: "unknown";
324
+ /**
325
+ * A write's downstream tool answered `isError` itself, so `message` holds its
326
+ * words. They do not prove the write unapplied: its handler ran before it answered.
327
+ */
328
+ toolAnswered?: true;
329
+ /** On `unknown_tool`: nearby canonical addresses on the same connector. */
330
+ suggestions?: string[];
286
331
  /** The single model-facing entry point for every credential class. */
287
332
  nextAction?: {
333
+ tool: "skills";
334
+ arguments: Record<string, never>;
335
+ purpose: string;
336
+ } | {
337
+ action: "reconnect_host" | "contact_operator" | "inspect_capabilities" | "recover_result";
338
+ purpose: string;
339
+ } | {
288
340
  tool: "authorize_connector";
289
341
  arguments: {
290
342
  connector: string;
291
343
  };
292
344
  operatorHandoff: string;
345
+ } | {
346
+ /** An error that carries its handoff: pass this link on; no tool call is needed. */
347
+ url: string;
348
+ purpose: string;
293
349
  } | {
294
350
  tool: "search_tools";
295
351
  arguments: {
package/dist/errors.js CHANGED
@@ -15,6 +15,7 @@ const CLASSIFICATION_CODE_TABLE = {
15
15
  conflict: true,
16
16
  resource_template_ambiguous: true,
17
17
  resource_match_budget_exceeded: true,
18
+ unsupported_capability: true,
18
19
  input_required_unsupported: true,
19
20
  input_required_invalid: true,
20
21
  input_required_limit: true,
@@ -28,6 +29,7 @@ const CLASSIFICATION_CODE_TABLE = {
28
29
  ambiguous_tool_alias: true,
29
30
  catalog_lookup_failed: true,
30
31
  result_too_large: true,
32
+ result_not_retained: true,
31
33
  destructive_tool_requires_approval: true,
32
34
  read_only_endpoint: true,
33
35
  auth_replay_refused: true,
@@ -57,7 +59,7 @@ function boundedIssueText(value, maxChars) {
57
59
  if (value.length <= maxChars)
58
60
  return { value, truncated: false };
59
61
  return {
60
- value: `${value.slice(0, Math.max(0, maxChars - 1))}…`,
62
+ value: `${tokenPrefix(value, maxChars - 1)}…`,
61
63
  truncated: true,
62
64
  };
63
65
  }
@@ -90,12 +92,43 @@ const MAX_ECHOED_BYTES = 512;
90
92
  */
91
93
  const echoEncoder = new TextEncoder();
92
94
  const echoDecoder = new TextDecoder();
95
+ /** Whitespace and the quotes and brackets that delimit a link in text, HTML and JSON. Each is
96
+ * ASCII, so a cut before one splits no codepoint in UTF-8 or UTF-16. */
97
+ export const TOKEN_END = new Set([0x09, 0x0a, 0x0d, 0x20, 0x22, 0x3c, 0x3e, 0x60]);
93
98
  /**
94
- * Clamp a string to a UTF-8 byte budget, appending `…` when it clipped.
95
- * Short strings — the common case, and the one that has to stay exact — are
99
+ * The first `units` UTF-16 units of `value`, less any unfinished token. Every cut of text
100
+ * that output redaction reads later goes through this or a sibling below: a remnant shorter
101
+ * than a fragment matches nothing, so a credential registered after the cut must not leave
102
+ * its opening behind. A whole token is the only tail a cut can prove finished.
103
+ */
104
+ export function tokenPrefix(value, units) {
105
+ if (units >= value.length)
106
+ return value;
107
+ let end = Math.max(0, units);
108
+ while (end > 0 && !TOKEN_END.has(value.charCodeAt(end)))
109
+ end--;
110
+ return value.slice(0, end).trimEnd();
111
+ }
112
+ /**
113
+ * Clamp a string to a UTF-8 byte budget without cutting a token, appending `…` when it
114
+ * clipped. Short strings — the common case, and the one that has to stay exact — are
96
115
  * returned unchanged and untagged.
97
116
  */
98
117
  export function boundedEchoText(value, maxBytes = MAX_ECHOED_BYTES) {
118
+ const bytes = echoEncoder.encode(value);
119
+ if (bytes.length <= maxBytes)
120
+ return value;
121
+ let end = Math.max(0, maxBytes);
122
+ while (end > 0 && !TOKEN_END.has(bytes[end]))
123
+ end--;
124
+ return `${echoDecoder.decode(bytes.slice(0, end)).trimEnd()}…`;
125
+ }
126
+ /**
127
+ * Clamp a name for an operator's record, which no agent reads and no output redaction
128
+ * follows, to a UTF-8 byte budget. Its prefix is what identifies it, so it may end inside a
129
+ * token; agent-facing text goes through {@link boundedEchoText}.
130
+ */
131
+ export function clampedRecordName(value, maxBytes) {
99
132
  const bytes = echoEncoder.encode(value);
100
133
  if (bytes.length <= maxBytes)
101
134
  return value;
@@ -106,6 +139,50 @@ export function boundedEchoText(value, maxBytes = MAX_ECHOED_BYTES) {
106
139
  end--;
107
140
  return `${echoDecoder.decode(bytes.slice(0, end))}…`;
108
141
  }
142
+ /**
143
+ * How much of a tool's own `isError` text reaches the agent. A refusal can be the
144
+ * only copy of what to do next: a paragraph of instructions, a request id and a
145
+ * signed approval URL, which alone commonly run to a couple of kilobytes. Four
146
+ * kilobytes holds those, and in a direct result's two copies stays well inside the
147
+ * default result cap; a tighter cap shrinks it further.
148
+ */
149
+ export const MAX_TOOL_ERROR_BYTES = 4_096;
150
+ const CLIPPED = "… [truncated]";
151
+ const CLIPPED_BYTES = echoEncoder.encode(CLIPPED).length;
152
+ /**
153
+ * Clamp downstream prose to a UTF-8 byte budget, marker included, without cutting
154
+ * a token. The cut backs off to a byte no URL or credential holds, so a link arrives
155
+ * whole or not at all, and no prefix of a credential registered later stays behind.
156
+ * Each such byte is ASCII, so no codepoint is split.
157
+ */
158
+ export function clipText(value, maxBytes = MAX_TOOL_ERROR_BYTES) {
159
+ const bytes = echoEncoder.encode(value);
160
+ if (bytes.length <= maxBytes)
161
+ return value;
162
+ let end = Math.max(0, maxBytes - CLIPPED_BYTES);
163
+ while (end > 0 && !TOKEN_END.has(bytes[end]))
164
+ end--;
165
+ return `${echoDecoder.decode(bytes.slice(0, end)).trimEnd()}${end > 0 ? " " : ""}${CLIPPED}`;
166
+ }
167
+ /**
168
+ * Clamp text so its JSON string fits `maxChars` serialized characters, quotes and marker
169
+ * included, without cutting a token. Escapes make a character cost up to six.
170
+ */
171
+ export function clipSerialized(value, maxChars) {
172
+ if (JSON.stringify(value).length <= maxChars)
173
+ return value;
174
+ const marked = (kept) => `${kept}${kept ? " " : ""}${CLIPPED}`;
175
+ let low = 0;
176
+ let high = Math.min(value.length, maxChars);
177
+ while (low < high) {
178
+ const mid = Math.ceil((low + high) / 2);
179
+ if (JSON.stringify(marked(value.slice(0, mid))).length <= maxChars)
180
+ low = mid;
181
+ else
182
+ high = mid - 1;
183
+ }
184
+ return marked(tokenPrefix(value, low));
185
+ }
109
186
  /**
110
187
  * Snapshot the caller's arguments after bounded schema-aware writeOnly omission,
111
188
  * then apply the shared byte budget without clipping. Unresolved sensitivity
@@ -337,11 +414,19 @@ const RETRYABLE_BY_CODE = {
337
414
  conflict: false,
338
415
  resource_template_ambiguous: false,
339
416
  resource_match_budget_exceeded: false,
417
+ unsupported_capability: false,
340
418
  input_required_unsupported: false,
341
419
  input_required_invalid: false,
342
420
  input_required_limit: false,
343
421
  connector_call_failed: false,
344
422
  };
423
+ /** The downstream said this caller has no access: affirmative evidence, unlike a failure to answer. */
424
+ export function isAccessRefusal(error) {
425
+ return (error instanceof ConnectorCallError &&
426
+ (error.code === "auth_required" ||
427
+ error.code === "downstream_oauth_required" ||
428
+ error.code === "provider_permission_denied"));
429
+ }
345
430
  /** Runtime membership in the closed connector taxonomy, checked against its exhaustive table. */
346
431
  export function isConnectorCallErrorCode(value) {
347
432
  return typeof value === "string" && Object.hasOwn(RETRYABLE_BY_CODE, value);